Skill: oozoofrog/osori (version 1.6.1)

--- name: osori description: "Osori v1.6.1 — Local project registry & context loader with Telegram slash commands. Registry versioning + auto-migration + root filters + root management + doctor (preview-first + risk gate) + safe root remove + switch multi-match + GitHub count cache + alias/favorite + Entire integration commands. Find, switch, list, add/remove projects, check status. Triggers: work on X, find project X, list projects, project status, project switch. | 오소리 — 텔레그램 슬래시 명령어 지원 로컬 프로젝트 레지스트리." homepage: https://github.com/oozoofrog/osori metadata: { "openclaw": { "emoji": "🦦", "requires": { "bins": ["python3", "git", "gh"] }, "os": ["darwin", "linux"] } } --- # Osori (오소리) Local project registry & context loader for AI agents. ## Prerequisites - **macOS**: `mdfind` (Spotlight, built-in), `python3`, `git`, `gh` CLI - **Linux**: `mdfind` unavailable → uses `find` as fallback automatically. `python3`, `git`, `gh` CLI required. - **Entire integration (optional)**: `entire` CLI installed (for `/entire-*` commands) ## Dependencies - **python3** — Required. Used for JSON processing. - **git** — Project detection and status checks. - **entire** — Optional. Required only for `/entire-status`, `/entire-enable`, `/entire-rewind-list`. ## Telegram Bot Commands (Updated in v1.6.1) Osori now supports Telegram slash commands for quick project management: ``` /list [root] — Show registered projects (optional root filter) /status [root] — Check status of projects (optional root filter) /find <name> [root|--root <root>] — Find a project by name (optional root scope) /switch <name> [root|--root <root>] [--index <n>] — Switch to project and load context (multi-match selection) /fingerprints [name] [--root <root>] — Show repo remote + last commit + open PR/issue counts /doctor [--fix] [--dry-run] [--yes] [--json] — Registry health check (preview-first, risk-gated) /list-roots — List roots, labels, paths, and project counts /root-add <key> [label] — Add root (or update label) /root-path-add <key> <path> — Add discovery path to root /root-path-remove <key> <path> — Remove discovery path from root /root-set-label <key> <label> — Update root label /root-remove <key> [--reassign <target>] [--force] — Safely remove root /alias-add <alias> <project> — Add alias for project /alias-remove <alias> — Remove alias /favorites — Show favorite projects /favorite-add <project> — Mark project as favorite /favorite-remove <project> — Unmark favorite /entire-status <project> [root|--root <root>] — Show Entire status in a project /entire-enable <project> [root|--root <root>] [--agent <name>] [--strategy <name>] — Enable Entire in a project /entire-rewind-list <project> [root|--root <root>] — List rewind points in a project /add <path> — Add project to registry /remove <name> — Remove project from registry /scan <path> [root] — Scan directory for git projects, optional root key /help — Show command help ``` ### Setup Add to your OpenClaw agent's TOOLS.md or Telegram bot config: ```bash # In Telegram bot commands (BotFather) list - Show all projects (or by root) status - Check project statuses (or by root) find - Find project by name switch - Switch to project fingerprints - Show repo/commit/PR/issue fingerprint doctor - Health check (preview-first, risk-gated fix) list-roots - Show roots and discovery paths root-add - Add root root-path-add - Add path to root root-path-remove - Remove path from root root-set-label - Rename root label root-remove - Safely remove root (with reassign/force options) alias-add - Add alias for a project alias-remove - Remove alias favorites - Show favorite projects favorite-add - Mark favorite project favorite-remove - Unmark favorite project entire-status - Show Entire status for a project entire-enable - Enable Entire for a project entire-rewind-list - List rewind points for a project add - Add project to registry remove - Remove project scan - Scan directory (optional root) help - Show help ``` ### Usage Examples ``` /list work /status personal /find agent-avengers work /switch Tesella --root personal /switch Tesella --root personal --index 1 /fingerprints Tesella --root personal /doctor --fix /list-roots /root-add work Work /root-path-add work /path/to/workspace /root-remove work --reassign default /alias-add rh RunnersHeart /favorite-add RunnersHeart /favorites /entire-status osori /entire-enable osori --agent claude-code --strategy manual-commit /add /Volumes/disk/MyProject /scan /path/to/workspace work ``` ## Registry `${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}` Override with the `OSORI_REGISTRY` environment variable. ### Versioning & Migration (v1.6.1) - Current schema: `osori.registry` - Current version: `2` - On every load, Osori auto-migrates older registry formats: - legacy array (`[]`) → versioned object - object without `schema/version` → normalized versioned object - Normalized registry fields: - top-level `roots[]` - top-level `aliases{}` (alias → project name) - each `projects[]` item includes `favorite: bool` - Migration safety: - creates backup: `osori.json.bak-<timestamp>` - corrupted JSON is preserved as: `osori.json.broken-<timestamp>` - write path uses atomic replace + rollback fallback ## Finding Projects (when path is unknown) When the project path is unknown, search in order: 1. **Registry lookup** — Fuzzy match name in `osori.json` 2. **mdfind** (macOS only) — `mdfind "kMDItemFSName == '<name>'" | head -5` 3. **find fallback** — Search priority: 1) `roots[].paths` from registry (if root is specified, that root first) 2) paths from `OSORI_SEARCH_PATHS` Command form: `find <search_paths> -maxdepth 4 -type d -name '<name>' 2>/dev/null` 4. **Ask the user** — If all methods fail, ask for the project path directly. 5. Offer to register the found project in the registry. ## Commands ### List Show all registered projects. Optional root filter supported in Telegram command: ```bash /list [root] ``` (Example: `/list work`) ### Switch Supports optional root scope and explicit candidate selection: ```bash /switch <name> [root|--root <root>] [--index <n>] ``` Flow: 1. Search registry (fuzzy match, root-scoped if provided) 2. If multiple matches: - show candidate list (name/root/path/last commit/dirty/score) - `--index <n>` to pick explicitly - no index => auto-pick highest score 3. If not found → run "Finding Projects" flow above and suggest add path 4. Load context: - `git status --short` - `git branch --show-current` - `git log --oneline -5` - `gh issue list -R <repo> --limit 5` (when repo is set) 5. Present summary Auto score policy: - +50 root exact (if root scope provided) - +30 name exact - +20 name prefix - +10 latest commit recency - -10 missing path - -5 repo missing - tie-break: latest commit, then name ### Fingerprints Show a one-shot project fingerprint view: - repo remote URL - last commit hash/date - open PR count - open issue count ```bash bash {baseDir}/scripts/project-fingerprints.sh [project-name] bash {baseDir}/scripts/project-fingerprints.sh --root <root-key> [project-name] ``` GitHub open-count cache (PR/Issue): - default cache file: `$HOME/.openclaw/osori-cache.json` - default TTL: `600` seconds - env overrides: - `OSORI_CACHE_FILE` - `OSORI_CACHE_TTL` ### Add ```bash bash {baseDir}/scripts/add-project.sh <path> [--tag <tag>] [--name <name>] ``` Auto-detects: git remote, language, description. ### Scan ```bash bash {baseDir}/scripts/scan-projects.sh <root-dir> [--depth 3] OSORI_ROOT_KEY=work bash {baseDir}/scripts/scan-projects.sh <root-dir> [--depth 3] ``` Bulk-scan a directory for git repos and add them to the registry. Telegram command supports optional root key too: ```bash /scan <path> [root] ``` ### Remove Delete an entry from `osori.json` by name. ### Status Run `git status` + `gh issue list` for one or all projects. Telegram root filter: ```bash /status [root] ``` ### Doctor Registry health check with preview-first pipeline and risk-gated fixes. **Default** (no flags): analyze + preview plan — no changes applied. ```bash /doctor # preview only (default) /doctor --fix # preview + apply (high-risk blocked) /doctor --fix --yes # preview + apply all (including high-risk) /doctor --dry-run # explicit preview only (never applies) /doctor --json # machine-readable JSON output ``` Risk levels: - 🟢 **low** — schema normalization, migration, missing fields - 🟡 **medium** — duplicate removal, root reference repair - 🔴 **high** — registry re-initialization from corrupted state Shell equivalent: ```bash bash {baseDir}/scripts/doctor.sh [--fix] [--dry-run] [--yes] [--json] ``` See also: [Doctor Safe Fix Guide](docs/examples/doctor-safe-fix.md) ### Root Management ```bash /list-roots /root-add <key> [label] /root-path-add <key> <path> /root-path-remove <key> <path> /root-set-label <key> <label> /root-remove <key> [--reassign <target>] [--force] ``` Shell equivalents: ```bash bash {baseDir}/scripts/root-manager.sh list bash {baseDir}/scripts/root-manager.sh add <key> [label] bash {baseDir}/scripts/root-manager.sh path-add <key> <path> bash {baseDir}/scripts/root-manager.sh path-remove <key> <path> bash {baseDir}/scripts/root-manager.sh set-label <key> <label> bash {baseDir}/scripts/root-manager.sh remove <key> [--reassign <target>] [--force] ``` Safety rules for remove: - `default` root cannot be removed - if projects exist in root: - `--reassign <target>` to move projects then remove - or `--force` to move projects to `default` and remove ### Alias & Favorites ```bash /alias-add <alias> <project> /alias-remove <alias> /favorites /favorite-add <project> /favorite-remove <project> ``` Shell equivalents: ```bash bash {baseDir}/scripts/alias-favorite-manager.sh alias-add <alias> <project> bash {baseDir}/scripts/alias-favorite-manager.sh alias-remove <alias> bash {baseDir}/scripts/alias-favorite-manager.sh aliases bash {baseDir}/scripts/alias-favorite-manager.sh favorite-add <project> bash {baseDir}/scripts/alias-favorite-manager.sh favorite-remove <project> bash {baseDir}/scripts/alias-favorite-manager.sh favorites ``` Aliases are case-insensitive and are resolved by `/find`, `/switch`, and `project-fingerprints.sh` name queries. ### Entire Integration Run Entire CLI commands in a registered project context: ```bash /entire-status <project> [root|--root <root>] /entire-enable <project> [root|--root <root>] [--agent <name>] [--strategy <name>] /entire-rewind-list <project> [root|--root <root>] ``` Shell equivalent: ```bash bash {baseDir}/scripts/entire-manager.sh status <project> [root|--root <root>] bash {baseDir}/scripts/entire-manager.sh enable <project> [root|--root <root>] [entire enable flags...] bash {baseDir}/scripts/entire-manager.sh rewind-list <project> [root|--root <root>] ``` Defaults: - `entire enable` defaults to `--agent claude-code --strategy manual-commit` when not provided. - `/entire-rewind-list` uses non-destructive JSON listing (`entire rewind --list`). ## Schema ```json { "schema": "osori.registry", "version": 2, "updatedAt": "2026-02-16T00:00:00Z", "roots": [ { "key": "default", "label": "Default", "paths": [] } ], "aliases": { "rh": "RunnersHeart" }, "projects": [ { "name": "string", "path": "/absolute/path", "repo": "owner/repo", "lang": "swift|typescript|python|rust|go|ruby|unknown", "tags": ["personal", "ios"], "description": "Short description", "addedAt": "YYYY-MM-DD", "root": "default", "favorite": false } ] } ``` ## Auto-trigger Rules - "work on X" / "X 프로젝트 작업하자" → switch X - "find project X" / "X 찾아줘" / "X 경로" → registry search or discover - "list projects" / "프로젝트 목록" → list - "add project" / "프로젝트 추가" → add - "project status" / "프로젝트 상태" → status all

path: README.md
content: # 🦦 Osori (오소리)

> Local project registry & context loader for AI agents.

Osori keeps track of all your local projects in a single JSON registry and lets AI agents (or you) find, switch, inspect, and manage them instantly — via Telegram slash commands or shell scripts.

## Features

- **📋 Project Registry** — Single-file JSON with auto-migration and atomic writes
- **🔀 Smart Switch** — Fuzzy match with multi-candidate scoring and `--index` selection
- **🌳 Multi-Root** — Organize projects into roots (e.g. `work`, `personal`) with discovery paths
- **🩺 Doctor** — Health check with safe auto-fix for registry issues
- **🧬 Fingerprints** — Git remote, last commit, open PR/issue counts at a glance
- **🏷️ Aliases** — Short names for quick access (`rh` → `RunnersHeart`)
- **⭐ Favorites** — Mark and list your go-to projects
- **📊 GitHub Cache** — TTL-based cache for PR/issue counts (no API spam)
- **🔗 Entire Integration** — Run [Entire](https://entire.io) CLI commands in project context

## Requirements

| Dependency | Required | Notes |
|---|---|---|
| `python3` | ✅ | JSON processing |
| `git` | ✅ | Project detection & status |
| `gh` | ✅ | GitHub PR/issue counts |
| `mdfind` | macOS only | Spotlight search (auto-fallback to `find` on Linux) |
| `entire` | Optional | Only for `/entire-*` commands |

## Installation

### Via [ClawHub](https://clawhub.com)

```bash
clawhub install osori
```

### Manual

Copy the skill folder into your OpenClaw workspace:

```bash
cp -r osori /path/to/.openclaw/workspace/skills/osori
```

## Quick Start

```bash
# Add a project
/add /path/to/my-project

# Scan a directory for all git repos
/scan /path/to/workspace

# List everything
/list

# Switch to a project (loads full context)
/switch my-project

# Check health
/doctor
```

## Commands

### Core

| Command | Description |
|---|---|
| `/list [root]` | Show registered projects |
| `/status [root]` | Check git status of projects |
| `/find <name> [--root <root>]` | Find a project by name |
| `/switch <name> [--root <root>] [--index <n>]` | Switch to project & load context |
| `/fingerprints [name] [--root <root>]` | Show repo fingerprint (remote, commit, PRs, issues) |
| `/add <path>` | Add project to registry |
| `/remove <name>` | Remove project from registry |
| `/scan <path> [root]` | Scan directory for git projects |
| `/doctor [--fix] [--json]` | Registry health check |

### Root Management

| Command | Description |
|---|---|
| `/list-roots` | List all roots with project counts |
| `/root-add <key> [label]` | Add or update a root |
| `/root-path-add <key> <path>` | Add discovery path to root |
| `/root-path-remove <key> <path>` | Remove discovery path |
| `/root-set-label <key> <label>` | Update root label |
| `/root-remove <key> [--reassign <target>] [--force]` | Safely remove root |

### Aliases & Favorites

| Command | Description |
|---|---|
| `/alias-add <alias> <project>` | Create shortcut name |
| `/alias-remove <alias>` | Remove alias |
| `/favorites` | List favorite projects |
| `/favorite-add <project>` | Mark as favorite |
| `/favorite-remove <project>` | Unmark favorite |

### Entire Integration

| Command | Description |
|---|---|
| `/entire-status <project> [--root <root>]` | Show Entire status |
| `/entire-enable <project> [--root <root>]` | Enable Entire (default: claude-code, manual-commit) |
| `/entire-rewind-list <project> [--root <root>]` | List rewind points |

## How Switch Works

```
/switch my-app
```

1. Fuzzy-match name in registry (scoped by root if provided)
2. If multiple matches → score & rank candidates:
   - `+50` root exact match
   - `+30` name exact match
   - `+20` name prefix match
   - `+10` most recent commit
   - `-10` missing path, `-5` no repo
3. Auto-select #1, or use `--index <n>` to pick
4. Load context: git status, branch, recent commits, open issues
5. Aliases resolve transparently (`/switch rh` → RunnersHeart)

## Registry Schema

```json
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [
    { "key": "default", "label": "Default", "paths": [] }
  ],
  "aliases": {
    "rh": "RunnersHeart"
  },
  "projects": [
    {
      "name": "MyProject",
      "path": "/absolute/path",
      "repo": "owner/repo",
      "lang": "swift",
      "tags": ["ios"],
      "root": "default",
      "favorite": false,
      "addedAt": "2026-02-16"
    }
  ]
}
```

**Safety guarantees:**
- Atomic write with rollback fallback
- Auto-backup on migration: `osori.json.bak-<timestamp>`
- Corrupted files preserved as: `osori.json.broken-<timestamp>`
- Auto-migration from legacy formats (v1 array → v2 object)

## Environment Variables

| Variable | Default | Description |
|---|---|---|
| `OSORI_REGISTRY` | `$HOME/.openclaw/osori.json` | Registry file path |
| `OSORI_SEARCH_PATHS` | — | Additional discovery paths (colon-separated) |
| `OSORI_CACHE_FILE` | `$HOME/.openclaw/osori-cache.json` | GitHub count cache |
| `OSORI_CACHE_TTL` | `600` | Cache TTL in seconds (≤0 forces refresh) |

## Project Structure

```
osori/
├── SKILL.md                          # OpenClaw skill definition
├── LICENSE
├── README.md
├── scripts/
│   ├── telegram-commands.sh          # Main command dispatcher
│   ├── registry_lib.py               # Core registry library
│   ├── add-project.sh                # /add
│   ├── scan-projects.sh              # /scan
│   ├── project-fingerprints.sh       # /fingerprints
│   ├── doctor.sh                     # /doctor
│   ├── root-manager.sh              # /root-* commands
│   ├── alias-favorite-manager.sh    # /alias-*, /favorite-*
│   ├── entire-manager.sh            # /entire-* commands
│   └── github_cache.py              # GitHub PR/issue count cache
├── tests/
│   └── run_tests.sh                  # 132 tests
└── docs/
    ├── roadmap-v1.5.md
    ├── multi-root-design.md
    └── v1.5-execution-board.md
```

## License

[MIT](LICENSE)


path: docs/examples/doctor-safe-fix.md
content: # Doctor: Safe Fix Guide

`/doctor`는 레지스트리 건강검진 + 안전한 자동 수정 도구입니다.

## 기본 동작 (Preview-First)

```bash
/doctor
```

**변경을 적용하지 않고** 분석 결과와 수정 계획만 보여줍니다:

```
🩺 Osori Doctor
Registry: ~/.openclaw/osori.json
Counts: ERROR=0 WARN=2 INFO=0

[WARN] registry.legacy_array: Legacy array registry detected.
  ↳ fix: Run with --fix to migrate to versioned schema.
[WARN] project.duplicate_name: duplicate project name 'my-app' at indices [0, 3]
  ↳ fix: Keep one canonical entry and remove duplicates.

📋 Fix Plan:
  1. 🟢 [LOW] Migrate legacy array format → versioned schema v2
     → 5 project(s)
  2. 🟡 [MEDIUM] Remove 1 duplicate(s) of 'my-app'
     → indices: [0, 3]

Risk summary: 🟢 low=1  🟡 medium=1  🔴 high=0

ℹ️  Preview only — no changes applied.
   Run with --fix to apply, or --dry-run to confirm preview.
```

## Risk 등급

| 등급 | 아이콘 | 설명 | 예시 |
|---|---|---|---|
| **low** | 🟢 | 스키마/버전 정규화 | 마이그레이션, 필드 추가 |
| **medium** | 🟡 | 데이터 변경 (복구 가능) | 중복 제거, root 참조 수정 |
| **high** | 🔴 | 파괴적 변경 | 깨진 레지스트리 초기화 |

## 수정 적용

```bash
/doctor --fix
```

- low/medium 수정을 적용합니다
- **high risk는 차단**됩니다 (별도 승인 필요)
- 적용 전 자동으로 `.bak-<timestamp>` 백업 생성

## High Risk 승인

```bash
/doctor --fix --yes
```

`--yes`를 추가하면 high risk 작업도 자동 승인합니다.

## Dry-Run (절대 미적용)

```bash
/doctor --dry-run
```

`--fix`와 함께 사용해도 **절대로 변경을 적용하지 않습니다**. CI/스크립트에서 안전하게 점검할 때 사용합니다.

## JSON 출력

```bash
/doctor --json
/doctor --dry-run --json
```

```json
{
  "status": "issues",
  "counts": { "error": 0, "warn": 2, "info": 0 },
  "plan": [
    { "risk": "low", "action": "migrate_legacy", "description": "..." },
    { "risk": "medium", "action": "dedupe_name", "description": "..." }
  ],
  "riskSummary": { "low": 1, "medium": 1, "high": 0 },
  "fix": { "requested": false, "dryRun": true, "applied": false }
}
```

## 안전 보장

1. **Preview-first** — 기본 실행은 항상 읽기 전용
2. **Backup** — 모든 변경 전 `.bak-<timestamp>` 자동 생성
3. **Corrupted 보존** — 깨진 파일은 `.broken-<timestamp>`로 보존
4. **Risk gate** — high risk는 명시적 승인 없이 실행 불가
5. **Dry-run** — `--dry-run` 플래그는 다른 모든 플래그를 override


path: docs/examples/multi-root-quickstart.md
content: # Multi-Root Quickstart

Osori의 multi-root 기능으로 프로젝트를 **업무/개인/실험** 등으로 분류할 수 있습니다.

## 1. Root 추가

```bash
/root-add work 업무
/root-add personal 개인
```

## 2. Discovery Path 설정

각 root에 프로젝트를 자동 탐색할 디렉토리를 등록합니다:

```bash
/root-path-add work ~/Developer/work
/root-path-add personal ~/Developer/personal
```

## 3. 일괄 스캔

```bash
/scan ~/Developer/work work
/scan ~/Developer/personal personal
```

한 번의 scan으로 해당 경로의 모든 git 프로젝트가 지정된 root에 등록됩니다.

## 4. Root별 조회

```bash
/list work          # 업무 프로젝트만
/list personal      # 개인 프로젝트만
/list               # 전체
```

```bash
/status work        # 업무 프로젝트 git status만
```

## 5. Root 범위로 Switch

```bash
/switch my-app --root work
```

같은 이름의 프로젝트가 여러 root에 있어도 scope를 지정하면 정확히 찾습니다.

## 6. Fingerprints도 Root 필터 지원

```bash
/fingerprints --root work    # 업무 프로젝트 핑거프린트만
```

## 7. Root 정리

더 이상 필요 없는 root는 안전하게 삭제:

```bash
# 프로젝트를 다른 root로 옮긴 후 삭제
/root-remove old-root --reassign work

# 또는 강제로 default에 옮기고 삭제
/root-remove old-root --force
```

`default` root는 삭제할 수 없습니다 (안전장치).

## Tips

- **Root는 논리적 분류**입니다 — 실제 파일 시스템 구조와 무관합니다
- Discovery path는 `/scan` 시 탐색 범위를 지정하는 용도입니다
- `/find`, `/switch`, `/fingerprints` 모두 `--root` 옵션을 지원합니다


path: docs/multi-root-design.md
content: # Osori Multi-root 지원 설계안 (work/personal)

작성일: 2026-02-16

## 목표

- `work`, `personal` 같은 루트를 분리해서 프로젝트를 관리
- 기존 단일 레지스트리와의 호환 유지
- 검색/스캔/상태 조회 시 루트 기준 필터 가능

---

## 설계 옵션 비교

## 옵션 A) 단일 인덱스 + 프로젝트별 `root` 필드

레지스트리 하나(`osori.json`)에 모든 프로젝트를 저장하되, 각 프로젝트에 `root` 값을 둔다.

예시:

```json
{
  "schema": "osori.registry",
  "version": 2,
  "roots": [
    { "key": "work", "label": "Work", "paths": ["/Volumes/eyedisk/develop/kakao"] },
    { "key": "personal", "label": "Personal", "paths": ["/Volumes/eyedisk/develop/oozoofrog"] }
  ],
  "projects": [
    { "name": "TalkiOSDashboard", "root": "work", "path": "..." },
    { "name": "RunnersHeart", "root": "personal", "path": "..." }
  ]
}
```

장점:
- 검색/중복검사/마이그레이션 로직이 단순
- 기존 단일 파일 워크플로와 자연스럽게 연결
- cross-root 조회가 쉬움 (`status all`, 전역 검색)

단점:
- 파일이 커질수록 merge conflict 가능성 증가
- 루트별 권한/동기화 정책 분리 시 제약

---

## 옵션 B) 루트별 인덱스 분리 (`osori-work.json`, `osori-personal.json`)

루트별로 레지스트리를 분리 저장하고, 상위 인덱스(혹은 설정)로 묶는다.

장점:
- 루트별 백업/권한/동기화 분리가 쉬움
- 대규모 환경에서 파일 충돌 완화

단점:
- 전역 검색/중복검사 구현 복잡
- 명령마다 멀티 파일 aggregate 필요
- 기존 단일 파일 사용자에게 마이그레이션 비용 증가

---

## 추천안

**옵션 A(단일 인덱스 + `root` 필드)** 추천.

이유:
1. 기존 사용자 호환성이 가장 높음
2. 마이그레이션 리스크가 낮음
3. 현재 Osori 사용 패턴(빠른 검색/전환)에 적합

단, 장기적으로 팀 단위 대규모 사용이 늘면 옵션 B 또는 하이브리드(읽기 전용 aggregate) 검토 가능.

---

## 단계별 구현 제안

### 1단계 (이번 릴리스)
- 레지스트리 v2 도입
- `projects[*].root` 추가 (기본값: `default`)
- `roots` 메타 필드 추가
- `OSORI_ROOT_KEY`로 add/scan 시 루트 지정

### 2단계
- `/list --root work` 필터
- `/status --root personal` 필터
- `/scan --root work <path>` 형태 지원

### 3단계
- 루트별 기본 탐색 경로(`roots[].paths`) 자동 사용
- 루트별 우선순위 검색/추천 전환

---

## 오픈 이슈

- 동일 프로젝트명이 다른 루트에 존재할 때 name 충돌 처리 정책
- root 미지정 프로젝트를 자동 분류할지 여부
- 루트 이름 변경 시 프로젝트 일괄 업데이트 UX


path: docs/roadmap-v1.5.md
content: # Osori v1.5 로드맵 (고정안)

작성일: 2026-02-16
상태: **FROZEN (기획 고정)**
기준 브랜치: `main`

---

## 0) 목표

v1.5는 "기능 추가"보다 **안정성 + 실사용 UX + 운영 효율**에 초점을 둔다.

핵심 방향:

1. 레지스트리/루트 운영의 안전성 강화
2. 프로젝트 전환(`/switch`) 경험 개선
3. GitHub 조회 비용/지연 감소
4. 팀/개인 모두에서 재사용 가능한 관리 기능 제공

---

## 1) 범위 (Scope)

## P0 (필수)

### A. `/doctor` 레지스트리 건강검진
- 검출 항목
  - path 미존재
  - 중복 project name/path
  - root 참조 불일치
  - repo 포맷 이상
  - registry schema/version 이상
- 출력
  - 요약(심각도별 count)
  - 항목별 fix 제안
- 옵션
  - `--fix` (안전 자동 수정)
  - `--json` (기계 파싱)

### B. `/root-remove <key>` 안전 삭제
- 제약
  - `default` 삭제 금지
  - 프로젝트가 남아있는 root 삭제 차단(기본)
- 옵션
  - `--reassign <target-root>`
  - `--force` (강제 삭제, 명시적 경고)

### C. `/switch` 다중 매치 UX 개선
- 동일/유사 이름 다수일 때 후보 목록 표시
- 후보 정보에 포함
  - name, root, path, 최근 커밋 시각, dirty 여부
- 선택 방식
  - `--index <n>` 직접 선택
  - 미선택 시 점수 기반 자동 선택(정책 아래 참고)

## P1 (권장)

### D. GitHub 조회 캐시
- 대상: PR/Issue open count
- TTL 기본값: 600초
- 캐시 저장: `$HOME/.openclaw/osori-cache.json`
- 실패 시 fallback: 기존 실시간 조회 유지

### E. Alias / Favorite
- alias 맵 지원: `aliases` 필드
- 즐겨찾기: `favorite: true`
- 명령
  - `/alias-add <alias> <project>`
  - `/alias-remove <alias>`
  - `/favorites`

---

## 2) 비범위 (Out of Scope)

- 원격 DB 도입
- 멀티 사용자 동시 편집 락 서버
- GUI 앱 제공
- GitHub 외 forge(GitLab/Bitbucket) 완전 지원

---

## 3) 설계 결정 (고정)

1. 레지스트리 단일 파일 유지 (`osori.json`)
2. multi-root는 `projects[*].root` + `roots[]` 메타 구조 유지
3. 저장 방식은 기존 원자적 replace + backup/rollback 유지
4. 신규 기능은 Telegram command + shell script 동시 지원

---

## 4) `/switch` 자동 선택 정책 (고정)

후보 점수 계산(높을수록 우선):

- +50: root 정확히 일치
- +30: 이름 정확 일치
- +20: 이름 prefix 일치
- +10: 최근 commit이 더 최신
- -10: path 미존재
- -5: repo 미설정

동점 시:
1. 최근 commit 최신
2. name 사전순

---

## 5) DoD (Definition of Done)

각 기능 공통 DoD:

1. 스크립트 도움말/usage 반영
2. SKILL.md 명령 문서 업데이트
3. 테스트 추가 및 통과
4. 하위 호환성(기존 레지스트리) 보장
5. 실패/예외 시 사용자 메시지 명확

추가 DoD:

- `/doctor`: 최소 8개 이상 검사 케이스 테스트
- `/root-remove`: 보호 규칙 + reassign/force 테스트
- `/switch` 다중매치: 후보 3개 이상 시나리오 테스트
- GitHub 캐시: TTL hit/miss 테스트

---

## 6) 테스트 계획

테스트 파일: `tests/run_tests.sh` 확장

추가 예정 테스트 그룹:

- `test_doctor_report_and_fix`
- `test_root_remove_safety_rules`
- `test_switch_multi_match_selection`
- `test_github_cache_ttl`
- `test_alias_and_favorites`

성공 기준:
- 전체 테스트 0 fail
- 신규 테스트 커버: 정상/오류/엣지 케이스 포함

---

## 7) 마이그레이션/호환성

- 기존 `projects[]` 중심 사용 패턴 유지
- 새 필드는 optional 추가
- 문서/명령은 점진적 도입
- 기존 사용자 명령은 깨지지 않아야 함

---

## 8) 릴리즈 플랜

### Phase 1 (v1.5.0-rc1)
- `/doctor`
- `/root-remove`
- `/switch` 후보 선택 UX

### Phase 2 (v1.5.0)
- GitHub 캐시
- alias/favorite
- 문서/테스트 확정

---

## 9) 리스크 및 대응

1. **복잡도 증가**
   - 대응: 명령별 책임 분리(`scripts/*-manager.sh`)
2. **캐시 일관성 이슈**
   - 대응: TTL 짧게 시작 + 실패 시 실시간 fallback
3. **사용자 혼란(옵션 증가)**
   - 대응: `/help` 예시 강화 + 안전 기본값 유지

---

## 10) 변경 관리 규칙

- 이 문서는 v1.5 구현 기준선이다.
- 범위 변경은 아래 절차로만 허용:
  1. `docs/roadmap-v1.5.md`에 변경 이력 추가
  2. 영향 범위(P0/P1, 테스트, 일정) 명시
  3. 커밋 메시지에 `roadmap:` prefix 사용

---

## 11) 변경 이력

- 2026-02-16: 초안 작성 및 FROZEN 고정

path: docs/v1.5-execution-board.md
content: # Osori v1.5 실행 보드

상태: IN_PROGRESS
기준 문서: `docs/roadmap-v1.5.md`
트래킹 이슈: #13

---

## Phase 1 (v1.5.0-rc1)

- [ ] #8 `[P0-A] /doctor 레지스트리 건강검진`
- [ ] #9 `[P0-B] /root-remove 안전 삭제`
- [ ] #10 `[P0-C] /switch 다중 매치 UX`

## Phase 2 (v1.5.0)

- [ ] #11 `[P1-D] GitHub PR/Issue 카운트 TTL 캐시`
- [ ] #12 `[P1-E] Alias/Favorite`

---

## 운영 규칙

1. 모든 구현 PR은 최소 1개 이슈를 `Closes #N`으로 연결한다.
2. 범위 변경 시 `docs/roadmap-v1.5.md` 변경 이력을 먼저 업데이트한다.
3. 테스트는 `tests/run_tests.sh` 기준 0 fail 유지.

---

## 진행 로그

- 2026-02-16: 이슈 #8~#13 생성, 실행 보드 초기화.


path: scripts/add-project.sh
content: #!/usr/bin/env bash
# Add a single project to the registry
# Usage: add-project.sh <path> [--tag <tag>] [--name <name>]

set -euo pipefail

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <path> [--tag <tag>] [--name <name>]"
  exit 1
fi

PROJECT_PATH_RAW="$1"
shift

if [[ ! -d "$PROJECT_PATH_RAW" ]]; then
  echo "Directory not found: $PROJECT_PATH_RAW"
  exit 1
fi

PROJECT_PATH="$(cd "$PROJECT_PATH_RAW" && pwd)"
NAME="$(basename "$PROJECT_PATH")"
TAG=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --tag) TAG="${2:-}"; shift 2 ;;
    --name) NAME="${2:-}"; shift 2 ;;
    *) shift ;;
  esac
done

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
ROOT_KEY="${OSORI_ROOT_KEY:-default}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Detect remote
REPO=""
REMOTE=$(git -C "$PROJECT_PATH" remote get-url origin 2>/dev/null || true)

if [[ -n "$REMOTE" ]]; then
  REPO=$(OSORI_REMOTE="$REMOTE" PYTHONPATH="$SCRIPT_DIR" python3 - << 'PYEOF'
import os
from registry_lib import parse_repo_from_remote
print(parse_repo_from_remote(os.environ.get("OSORI_REMOTE", "")))
PYEOF
)
fi

# Detect language
LANG_DETECTED="unknown"
if [[ -f "$PROJECT_PATH/Package.swift" ]]; then LANG_DETECTED="swift"
elif [[ -f "$PROJECT_PATH/package.json" ]]; then LANG_DETECTED="typescript"
elif [[ -f "$PROJECT_PATH/Cargo.toml" ]]; then LANG_DETECTED="rust"
elif [[ -f "$PROJECT_PATH/go.mod" ]]; then LANG_DETECTED="go"
elif [[ -f "$PROJECT_PATH/pyproject.toml" ]] || [[ -f "$PROJECT_PATH/setup.py" ]]; then LANG_DETECTED="python"
elif [[ -f "$PROJECT_PATH/Gemfile" ]]; then LANG_DETECTED="ruby"
fi

# Detect description safely
DESC=""
if [[ -f "$PROJECT_PATH/package.json" ]]; then
  DESC=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('description',''))" < "$PROJECT_PATH/package.json" 2>/dev/null || true)
fi

TODAY=$(date +%Y-%m-%d)

OSORI_SCRIPT_DIR="$SCRIPT_DIR" \
OSORI_REG="$REGISTRY_FILE" \
OSORI_NAME="$NAME" \
OSORI_PATH="$PROJECT_PATH" \
OSORI_REPO="$REPO" \
OSORI_LANG="$LANG_DETECTED" \
OSORI_TAG="$TAG" \
OSORI_DESC="$DESC" \
OSORI_TODAY="$TODAY" \
OSORI_ROOT_KEY="$ROOT_KEY" \
python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import (
    ensure_root_exists,
    load_registry,
    registry_projects,
    save_registry,
    set_registry_projects,
)

reg_file = os.environ["OSORI_REG"]
name = os.environ["OSORI_NAME"]
path = os.environ["OSORI_PATH"]
repo = os.environ["OSORI_REPO"]
lang = os.environ["OSORI_LANG"]
tag = os.environ["OSORI_TAG"]
desc = os.environ["OSORI_DESC"]
today = os.environ["OSORI_TODAY"]
root_key = os.environ["OSORI_ROOT_KEY"] or "default"

loaded = load_registry(reg_file, auto_migrate=True, make_backup_on_migrate=True)
registry = loaded.registry
resolved_root = ensure_root_exists(registry, root_key)
projects = registry_projects(registry)

for p in projects:
    if p.get("name") == name:
        print(f"Already registered: {name}")
        if loaded.migrated:
            notes = "; ".join(loaded.migration_notes)
            print(f"Migrated registry on load: {notes}")
            if loaded.backup_path:
                print(f"Migration backup: {loaded.backup_path}")
        raise SystemExit(0)

entry = {
    "name": name,
    "path": path,
    "repo": repo,
    "lang": lang,
    "tags": [tag] if tag else [],
    "description": desc,
    "addedAt": today,
    "root": resolved_root,
}

projects.append(entry)
set_registry_projects(registry, projects)
backup_path = save_registry(reg_file, registry, make_backup=True)

if loaded.migrated:
    notes = "; ".join(loaded.migration_notes)
    print(f"Migrated registry on load: {notes}")
    if loaded.backup_path:
        print(f"Migration backup: {loaded.backup_path}")

if backup_path:
    print(f"Backup: {backup_path}")

print(f"Added: {name} ({path})")
PYEOF

path: scripts/alias-favorite-manager.sh
content: #!/usr/bin/env bash
# Manage aliases and favorites in osori registry
# Usage:
#   alias-favorite-manager.sh alias-add <alias> <project>
#   alias-favorite-manager.sh alias-remove <alias>
#   alias-favorite-manager.sh aliases
#   alias-favorite-manager.sh favorite-add <project-or-alias>
#   alias-favorite-manager.sh favorite-remove <project-or-alias>
#   alias-favorite-manager.sh favorites

set -euo pipefail

COMMAND="${1:-}"
if [[ $# -gt 0 ]]; then
  shift
fi

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

usage() {
  cat << 'EOF'
Usage:
  alias-favorite-manager.sh alias-add <alias> <project>
  alias-favorite-manager.sh alias-remove <alias>
  alias-favorite-manager.sh aliases
  alias-favorite-manager.sh favorite-add <project-or-alias>
  alias-favorite-manager.sh favorite-remove <project-or-alias>
  alias-favorite-manager.sh favorites
EOF
}

case "$COMMAND" in
  alias-add)
    ALIAS_KEY="${1:-}"
    PROJECT_QUERY="${2:-}"
    [[ -z "$ALIAS_KEY" || -z "$PROJECT_QUERY" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ALIAS="$ALIAS_KEY" OSORI_QUERY="$PROJECT_QUERY" python3 << 'PYEOF'
import os
import re
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import (
    load_registry,
    registry_aliases,
    registry_projects,
    save_registry,
    set_registry_aliases,
)

alias_key = os.environ["OSORI_ALIAS"].strip().lower()
query = os.environ["OSORI_QUERY"].strip().lower()

if not re.match(r"^[A-Za-z0-9_-]+$", alias_key):
    print("❌ alias must match [A-Za-z0-9_-]+")
    raise SystemExit(1)

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
projects = registry_projects(registry)

matches = [p for p in projects if query == str(p.get("name", "")).lower()]
if not matches:
    matches = [p for p in projects if query in str(p.get("name", "")).lower()]

if not matches:
    print(f"❌ project not found: {os.environ['OSORI_QUERY']}")
    raise SystemExit(1)
if len(matches) > 1:
    names = ", ".join(str(p.get("name", "")) for p in matches[:5])
    print(f"❌ ambiguous project query: {os.environ['OSORI_QUERY']} ({names})")
    raise SystemExit(1)

target_name = str(matches[0].get("name", "")).strip()
if not target_name:
    print("❌ selected project has invalid name")
    raise SystemExit(1)

aliases = registry_aliases(registry)
aliases[alias_key] = target_name
set_registry_aliases(registry, aliases)
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

print(f"✅ alias added: {alias_key} -> {target_name}")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  alias-remove)
    ALIAS_KEY="${1:-}"
    [[ -z "$ALIAS_KEY" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ALIAS="$ALIAS_KEY" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_aliases, save_registry, set_registry_aliases

alias_key = os.environ["OSORI_ALIAS"].strip().lower()
res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
aliases = registry_aliases(registry)

if alias_key not in aliases:
    print(f"❌ alias not found: {alias_key}")
    raise SystemExit(1)

removed = aliases.pop(alias_key)
set_registry_aliases(registry, aliases)
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

print(f"✅ alias removed: {alias_key} (was -> {removed})")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  aliases)
    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_aliases

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
aliases = registry_aliases(res.registry)

if not aliases:
    print("📎 No aliases configured.")
    raise SystemExit(0)

print(f"📎 Aliases ({len(aliases)})")
for k in sorted(aliases.keys()):
    print(f"- {k} -> {aliases[k]}")
PYEOF
    ;;

  favorite-add|favorite-remove)
    PROJECT_QUERY="${1:-}"
    [[ -z "$PROJECT_QUERY" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_QUERY="$PROJECT_QUERY" OSORI_COMMAND="$COMMAND" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects, resolve_alias, save_registry, set_registry_projects

query_raw = os.environ["OSORI_QUERY"].strip()
cmd = os.environ["OSORI_COMMAND"]
mark_value = cmd == "favorite-add"

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
projects = registry_projects(registry)
query = resolve_alias(query_raw, registry).lower()

matches = [p for p in projects if query == str(p.get("name", "")).lower()]
if not matches:
    matches = [p for p in projects if query in str(p.get("name", "")).lower()]

if not matches:
    print(f"❌ project not found: {query_raw}")
    raise SystemExit(1)
if len(matches) > 1:
    names = ", ".join(str(p.get("name", "")) for p in matches[:5])
    print(f"❌ ambiguous project query: {query_raw} ({names})")
    raise SystemExit(1)

target_name = str(matches[0].get("name", ""))
changed = False
for p in projects:
    if str(p.get("name", "")) == target_name:
        if bool(p.get("favorite", False)) != mark_value:
            p["favorite"] = mark_value
            changed = True
        break

if not changed:
    state = "already favorite" if mark_value else "already not favorite"
    print(f"ℹ️ {target_name}: {state}")
    raise SystemExit(0)

set_registry_projects(registry, projects)
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

if mark_value:
    print(f"⭐ favorite added: {target_name}")
else:
    print(f"✅ favorite removed: {target_name}")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  favorites)
    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
projects = registry_projects(res.registry)
favs = [p for p in projects if bool(p.get("favorite", False))]

if not favs:
    print("⭐ No favorite projects.")
    raise SystemExit(0)

favs.sort(key=lambda p: str(p.get("name", "")).lower())
print(f"⭐ Favorites ({len(favs)})")
for p in favs:
    name = p.get("name", "-")
    root = p.get("root", "default")
    path = p.get("path", "-")
    print(f"- {name} [{root}] | {path}")
PYEOF
    ;;

  -h|--help|help)
    usage
    ;;

  *)
    usage
    exit 1
    ;;
esac

path: scripts/doctor.sh
content: #!/usr/bin/env bash
# Registry health checker for Osori
# Usage: doctor.sh [--fix] [--dry-run] [--yes] [--json]
#
# Default (no flags):  analyze + preview (no changes applied)
# --fix:               analyze + preview + apply (prompts for high-risk)
# --dry-run:           analyze + preview only (explicit, never applies)
# --yes:               auto-approve low/medium risk (high still requires explicit --fix)
# --json:              machine-readable JSON output

set -euo pipefail

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DO_FIX="false"
DRY_RUN="false"
AUTO_YES="false"
OUT_JSON="false"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --fix)
      DO_FIX="true"
      shift
      ;;
    --dry-run)
      DRY_RUN="true"
      shift
      ;;
    --yes)
      AUTO_YES="true"
      shift
      ;;
    --json)
      OUT_JSON="true"
      shift
      ;;
    -h|--help|help)
      cat << 'EOF'
Usage:
  doctor.sh [--fix] [--dry-run] [--yes] [--json]

Modes:
  (default)   Analyze + preview plan (no changes applied)
  --fix       Analyze + preview + apply fixes
  --dry-run   Analyze + preview only (never applies, explicit)
  --yes       Auto-approve low/medium risk fixes (high risk still blocked without --fix)
  --json      Machine-readable JSON report

Risk Levels:
  low     Schema normalization, missing fields, migration
  medium  Duplicate removal, root reference repair
  high    Registry re-initialization from corrupted state
EOF
      exit 0
      ;;
    *)
      echo "Unknown option: $1"
      exit 1
      ;;
  esac
done

OSORI_SCRIPT_DIR="$SCRIPT_DIR" \
OSORI_REG="$REGISTRY_FILE" \
OSORI_DO_FIX="$DO_FIX" \
OSORI_DRY_RUN="$DRY_RUN" \
OSORI_AUTO_YES="$AUTO_YES" \
OSORI_OUT_JSON="$OUT_JSON" \
python3 << 'PYEOF'
import json
import os
import re
import sys
from collections import defaultdict

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import (
    REGISTRY_SCHEMA,
    REGISTRY_VERSION,
    load_registry,
    registry_projects,
    registry_roots,
    save_registry,
    set_registry_projects,
)

reg_path = os.environ["OSORI_REG"]
do_fix = os.environ.get("OSORI_DO_FIX", "false").lower() == "true"
dry_run = os.environ.get("OSORI_DRY_RUN", "false").lower() == "true"
auto_yes = os.environ.get("OSORI_AUTO_YES", "false").lower() == "true"
out_json = os.environ.get("OSORI_OUT_JSON", "false").lower() == "true"

# dry-run overrides fix
if dry_run:
    do_fix = False

findings = []
plan = []  # list of planned fix actions


# ── helpers ──────────────────────────────────────────

def add(severity, code, message, suggestion=None, project=None):
    row = {"severity": severity, "code": code, "message": message}
    if suggestion:
        row["suggestion"] = suggestion
    if project:
        row["project"] = project
    findings.append(row)


def plan_action(risk, action, description, detail=None):
    """Queue a fix action with risk level."""
    entry = {"risk": risk, "action": action, "description": description}
    if detail:
        entry["detail"] = detail
    plan.append(entry)


def count_by_severity(rows):
    c = {"error": 0, "warn": 0, "info": 0}
    for r in rows:
        sev = r.get("severity", "info")
        if sev not in c:
            c[sev] = 0
        c[sev] += 1
    return c


def risk_order(risk):
    return {"low": 0, "medium": 1, "high": 2}.get(risk, 9)


def repo_valid(repo):
    if repo == "":
        return True
    return re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", repo) is not None


# ── Phase 1: Analyze ────────────────────────────────

raw_payload = None
raw_ok = False
registry_corrupted = False
registry_missing = False

if os.path.exists(reg_path):
    try:
        with open(reg_path, encoding="utf-8") as f:
            raw_payload = json.load(f)
            raw_ok = True
    except Exception as e:
        add(
            "error", "registry.corrupted",
            f"Registry JSON parse failed: {e}",
            "Run with --fix to preserve broken file and reinitialize safely.",
        )
        registry_corrupted = True
        plan_action("high", "reinitialize",
                     "Preserve broken file as .broken-<ts> and create fresh registry",
                     f"source: {reg_path}")
else:
    add("warn", "registry.missing", f"Registry file not found: {reg_path}",
        "Run with --fix to initialize registry.")
    registry_missing = True
    plan_action("low", "initialize", f"Create new registry at {reg_path}")

needs_migration = False
needs_schema_fix = False
raw_projects = []
raw_roots = []

if raw_ok:
    if isinstance(raw_payload, list):
        add("warn", "registry.legacy_array",
            "Legacy array registry detected.",
            "Run with --fix to migrate to versioned schema.")
        raw_projects = [p for p in raw_payload if isinstance(p, dict)]
        raw_roots = [{"key": "default", "paths": []}]
        needs_migration = True
        plan_action("low", "migrate_legacy",
                     f"Migrate legacy array format → versioned schema v{REGISTRY_VERSION}",
                     f"{len(raw_projects)} project(s)")
    elif isinstance(raw_payload, dict):
        schema = raw_payload.get("schema")
        version = raw_payload.get("version")

        if schema != REGISTRY_SCHEMA:
            add("warn", "registry.schema_mismatch",
                f"schema={schema!r}, expected {REGISTRY_SCHEMA!r}",
                "Run with --fix to normalize schema.")
            needs_schema_fix = True
            plan_action("low", "fix_schema",
                         f"Update schema: {schema!r} → {REGISTRY_SCHEMA!r}")

        try:
            version_int = int(version)
        except Exception:
            version_int = None

        if version_int != REGISTRY_VERSION:
            add("warn", "registry.version_mismatch",
                f"version={version!r}, expected {REGISTRY_VERSION}",
                "Run with --fix to migrate version.")
            needs_migration = True
            plan_action("low", "migrate_version",
                         f"Migrate version: {version!r} → {REGISTRY_VERSION}")

        raw_projects = raw_payload.get("projects", [])
        if not isinstance(raw_projects, list):
            add("error", "registry.projects_invalid_type",
                f"projects field is {type(raw_projects).__name__}, expected list",
                "Run with --fix to normalize projects container.")
            raw_projects = []

        raw_roots = raw_payload.get("roots", [])
        if not isinstance(raw_roots, list):
            add("warn", "registry.roots_invalid_type",
                f"roots field is {type(raw_roots).__name__}, expected list",
                "Run with --fix to normalize roots container.")
            raw_roots = []
    else:
        add("error", "registry.invalid_top_level",
            f"top-level JSON type is {type(raw_payload).__name__}, expected object/list",
            "Run with --fix to reset registry format safely.")
        raw_projects = []
        raw_roots = []

    # root key set
    root_keys = set()
    for r in raw_roots:
        if isinstance(r, dict):
            key = str(r.get("key", "")).strip()
            if key:
                root_keys.add(key)

    # project checks
    name_map = defaultdict(list)
    path_map = defaultdict(list)

    for idx, p in enumerate(raw_projects):
        if not isinstance(p, dict):
            add("error", "project.invalid_item",
                f"projects[{idx}] is not an object",
                "Run with --fix to normalize registry entries.")
            continue

        name = str(p.get("name", "")).strip()
        path = str(p.get("path", "")).strip()
        root = str(p.get("root", "default") or "default").strip() or "default"
        repo = str(p.get("repo", "") or "")

        if not name or not path:
            add("error", "project.missing_required",
                f"projects[{idx}] missing required fields name/path",
                "Run with --fix to remove invalid project entries.")
            continue

        name_map[name].append(idx)
        path_map[path].append(idx)

        if root_keys and root not in root_keys:
            add("warn", "project.root_reference_missing",
                f"project '{name}' references unknown root '{root}'",
                "Run with --fix to add missing root metadata automatically.",
                project=name)
            plan_action("medium", "add_missing_root",
                         f"Add missing root '{root}' referenced by project '{name}'",
                         f"root={root}")

        if not repo_valid(repo):
            add("warn", "project.repo_invalid_format",
                f"project '{name}' has invalid repo format: {repo!r}",
                "Use owner/repo format (or leave empty).",
                project=name)

        if not os.path.exists(path):
            add("error", "project.path_missing",
                f"project '{name}' path does not exist: {path}",
                "Remove project or update to a valid path.",
                project=name)

    for n, idxs in name_map.items():
        if len(idxs) > 1:
            add("warn", "project.duplicate_name",
                f"duplicate project name '{n}' at indices {idxs}",
                "Keep one canonical entry and remove duplicates.",
                project=n)
            plan_action("medium", "dedupe_name",
                         f"Remove {len(idxs) - 1} duplicate(s) of '{n}'",
                         f"indices: {idxs}")

    for pth, idxs in path_map.items():
        if len(idxs) > 1:
            add("warn", "project.duplicate_path",
                f"duplicate project path '{pth}' at indices {idxs}",
                "Keep one canonical entry and remove duplicates.")
            plan_action("medium", "dedupe_path",
                         f"Remove {len(idxs) - 1} duplicate path(s): {pth}",
                         f"indices: {idxs}")


# ── Phase 2: Plan (dedupe plan actions) ─────────────

# Deduplicate plan entries by action key
seen_actions = set()
unique_plan = []
for p in plan:
    key = (p["action"], p.get("detail", ""))
    if key not in seen_actions:
        seen_actions.add(key)
        unique_plan.append(p)
plan = unique_plan

# Classify risk summary
risk_summary = {"low": 0, "medium": 0, "high": 0}
for p in plan:
    risk_summary[p["risk"]] = risk_summary.get(p["risk"], 0) + 1

has_high_risk = risk_summary.get("high", 0) > 0


# ── Phase 3: Preview ────────────────────────────────
# (output is generated at the end)


# ── Phase 4: Apply (plan-driven, only if --fix) ─────

fix_applied = False
fix_backup = None
migration_backup = None
migration_notes = []
dedupe_removed = 0
actions_applied = []
actions_blocked = []


def apply_action(action_entry):
    """Execute a single plan action. Returns True if applied."""
    act = action_entry["action"]

    if act in ("migrate_legacy", "migrate_version", "fix_schema", "initialize"):
        # Handled by load_registry(auto_migrate=True)
        return True

    if act == "reinitialize":
        # Handled by load_registry corruption path
        return True

    if act in ("dedupe_name", "dedupe_path"):
        # Handled by dedupe pass below
        return True

    if act == "add_missing_root":
        # Handled by load_registry normalization (adds missing roots)
        return True

    return False


if do_fix and plan:
    # Gate actions by risk level
    for p in plan:
        risk = p["risk"]
        if risk == "high" and not auto_yes:
            actions_blocked.append(p)
            continue
        if apply_action(p):
            actions_applied.append(p)
        else:
            actions_blocked.append(p)

    # Run load_registry to apply migration/normalization actions
    loaded = load_registry(reg_path, auto_migrate=True, make_backup_on_migrate=True)
    migration_notes = loaded.migration_notes if loaded.migrated else []
    migration_backup = loaded.backup_path
    registry = loaded.registry

    # Dedupe pass (applies dedupe_name / dedupe_path actions)
    has_dedupe = any(a["action"] in ("dedupe_name", "dedupe_path") for a in actions_applied)
    if has_dedupe:
        projects = registry_projects(registry)
        seen = set()
        deduped = []
        for p in projects:
            key = (str(p.get("name", "")).strip(), str(p.get("path", "")).strip())
            if key in seen:
                dedupe_removed += 1
                continue
            seen.add(key)
            deduped.append(p)

        if dedupe_removed > 0:
            set_registry_projects(registry, deduped)
            fix_backup = save_registry(reg_path, registry, make_backup=True)

    fix_applied = True

    # Report applied actions
    if migration_notes:
        add("info", "fix.migration_applied", f"migration applied: {'; '.join(migration_notes)}")
    if migration_backup:
        add("info", "fix.migration_backup", f"migration backup: {migration_backup}")
    if dedupe_removed > 0:
        add("info", "fix.dedupe_applied", f"removed exact duplicate entries: {dedupe_removed}")
        if fix_backup:
            add("info", "fix.backup_created", f"backup created: {fix_backup}")
    if not migration_notes and dedupe_removed == 0:
        add("info", "fix.noop", "no safe fix changes were required")

elif do_fix and not plan:
    fix_applied = True
    add("info", "fix.noop", "no safe fix changes were required")


# ── Output ──────────────────────────────────────────

counts = count_by_severity(findings)
status = "ok" if counts.get("error", 0) == 0 and counts.get("warn", 0) == 0 else "issues"

report = {
    "status": status,
    "registry": reg_path,
    "counts": counts,
    "findings": findings,
    "plan": plan,
    "riskSummary": risk_summary,
    "fix": {
        "requested": do_fix,
        "dryRun": dry_run,
        "applied": fix_applied,
        "actionsApplied": len(actions_applied),
        "actionsBlocked": len(actions_blocked),
        "migrationNotes": migration_notes,
        "migrationBackup": migration_backup,
        "dedupeRemoved": dedupe_removed,
        "backup": fix_backup,
    },
}

if out_json:
    print(json.dumps(report, ensure_ascii=False, indent=2))
    raise SystemExit(0)

# Human-readable output
print("🩺 Osori Doctor")
print(f"Registry: {reg_path}")
print(f"Counts: ERROR={counts.get('error', 0)} WARN={counts.get('warn', 0)} INFO={counts.get('info', 0)}")
print()

if not findings and not plan:
    print("✅ No issues found.")
    raise SystemExit(0)

# Findings
if findings:
    order = {"error": 0, "warn": 1, "info": 2}
    for row in sorted(findings, key=lambda r: (order.get(r.get("severity", "info"), 9), r.get("code", ""))):
        sev = row["severity"].upper()
        print(f"[{sev}] {row['code']}: {row['message']}")
        if row.get("project"):
            print(f"  ↳ project: {row['project']}")
        if row.get("suggestion"):
            print(f"  ↳ fix: {row['suggestion']}")

# Plan preview
if plan:
    print()
    print("📋 Fix Plan:")
    risk_icons = {"low": "🟢", "medium": "🟡", "high": "🔴"}
    for i, p in enumerate(plan, 1):
        icon = risk_icons.get(p["risk"], "⚪")
        print(f"  {i}. {icon} [{p['risk'].upper()}] {p['description']}")
        if p.get("detail"):
            print(f"     → {p['detail']}")

    print()
    lo = risk_summary.get("low", 0)
    med = risk_summary.get("medium", 0)
    hi = risk_summary.get("high", 0)
    print(f"Risk summary: 🟢 low={lo}  🟡 medium={med}  🔴 high={hi}")

if plan and not do_fix and not dry_run:
    print()
    print("ℹ️  Preview only — no changes applied.")
    print("   Run with --fix to apply, or --dry-run to confirm preview.")

if dry_run:
    print()
    print("ℹ️  Dry-run mode — no changes applied.")

if do_fix:
    print()
    if actions_blocked:
        print(f"⚠️  {len(actions_blocked)} high-risk action(s) blocked (use --yes to auto-approve):")
        for b in actions_blocked:
            print(f"   🔴 {b['description']}")
    if fix_applied:
        print("🔧 Fix applied.")
PYEOF


path: scripts/entire-manager.sh
content: #!/usr/bin/env bash
# Manage Entire CLI per registered project
# Usage:
#   entire-manager.sh status <project> [root|--root <root>]
#   entire-manager.sh enable <project> [root|--root <root>] [entire enable flags...]
#   entire-manager.sh rewind-list <project> [root|--root <root>]

set -euo pipefail

COMMAND="${1:-}"
if [[ $# -gt 0 ]]; then
  shift
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"

PROJECT_QUERY=""
ROOT_FILTER=""
EXTRA_ARGS=()
PROJECT_NAME=""
PROJECT_PATH=""
PROJECT_ROOT=""

usage() {
  cat << 'EOF'
Usage:
  entire-manager.sh status <project> [root|--root <root>]
  entire-manager.sh enable <project> [root|--root <root>] [entire enable flags...]
  entire-manager.sh rewind-list <project> [root|--root <root>]

Examples:
  entire-manager.sh status osori
  entire-manager.sh enable osori --agent claude-code --strategy manual-commit
  entire-manager.sh rewind-list osori --root work
EOF
}

ensure_entire() {
  if ! command -v entire >/dev/null 2>&1; then
    echo "❌ 'entire' CLI not found. Install first: brew tap entireio/tap && brew install entireio/tap/entire"
    exit 1
  fi
}

parse_target_and_extra() {
  PROJECT_QUERY=""
  ROOT_FILTER=""
  EXTRA_ARGS=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --root)
        ROOT_FILTER="${2:-}"
        if [[ -z "$ROOT_FILTER" ]]; then
          echo "❌ --root requires a value"
          exit 1
        fi
        shift 2
        ;;
      --agent|--strategy|--telemetry|--to)
        if [[ $# -lt 2 ]]; then
          echo "❌ $1 requires a value"
          exit 1
        fi
        EXTRA_ARGS+=("$1" "$2")
        shift 2
        ;;
      --agent=*|--strategy=*|--telemetry=*|--to=*)
        EXTRA_ARGS+=("$1")
        shift
        ;;
      --local|--project|--skip-push-sessions|--force|--logs-only|--reset)
        EXTRA_ARGS+=("$1")
        shift
        ;;
      *)
        if [[ -z "$PROJECT_QUERY" ]]; then
          PROJECT_QUERY="$1"
        elif [[ -z "$ROOT_FILTER" ]]; then
          ROOT_FILTER="$1"
        else
          EXTRA_ARGS+=("$1")
        fi
        shift
        ;;
    esac
  done

  if [[ -z "$PROJECT_QUERY" ]]; then
    echo "❌ project query is required"
    exit 1
  fi
}

resolve_project() {
  local resolved
  resolved=$(OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_QUERY="$PROJECT_QUERY" OSORI_ROOT_FILTER="$ROOT_FILTER" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import filter_projects, load_registry, registry_projects

query = os.environ["OSORI_QUERY"].strip().lower()
root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
projects = filter_projects(registry_projects(res.registry), root_key=root_filter)

if not projects:
    if root_filter:
        print(f"❌ no projects found in root '{root_filter}'")
    else:
        print("❌ no projects registered")
    raise SystemExit(1)

exact = [p for p in projects if str(p.get("name", "")).lower() == query]
candidates = exact if exact else [p for p in projects if query in str(p.get("name", "")).lower()]

if not candidates:
    suffix = f" in root '{root_filter}'" if root_filter else ""
    print(f"❌ project '{os.environ['OSORI_QUERY']}' not found{suffix}")
    raise SystemExit(1)

if len(candidates) > 1:
    print(f"❌ ambiguous project query '{os.environ['OSORI_QUERY']}'. matches:")
    for i, p in enumerate(candidates[:10], start=1):
        print(f"  {i}. {p.get('name', '-')} [{p.get('root', 'default')}] | {p.get('path', '-')}")
    raise SystemExit(1)

target = candidates[0]
name = str(target.get("name", "")).strip()
path = str(target.get("path", "")).strip()
root = str(target.get("root", "default") or "default")

if not name or not path:
    print("❌ invalid project entry (missing name/path)")
    raise SystemExit(1)

print(f"{name}\t{path}\t{root}")
PYEOF
)

  IFS=$'\t' read -r PROJECT_NAME PROJECT_PATH PROJECT_ROOT <<< "$resolved"

  if [[ -z "$PROJECT_PATH" || ! -d "$PROJECT_PATH" ]]; then
    echo "❌ project path does not exist: $PROJECT_PATH"
    exit 1
  fi
}

run_for_project() {
  echo "📁 *$PROJECT_NAME*"
  echo "📍 $PROJECT_PATH"
  echo "🧭 root: $PROJECT_ROOT"
  echo

  local cwd
  cwd="$(pwd)"
  cd "$PROJECT_PATH"

  case "$COMMAND" in
    status)
      entire status --detailed
      ;;
    enable)
      local has_agent="false"
      local has_strategy="false"
      local -a enable_args=()

      for arg in "${EXTRA_ARGS[@]-}"; do
        [[ -z "$arg" ]] && continue
        enable_args+=("$arg")
      done

      for arg in "${enable_args[@]-}"; do
        case "$arg" in
          --agent|--agent=*) has_agent="true" ;;
          --strategy|--strategy=*) has_strategy="true" ;;
        esac
      done
      if [[ "$has_agent" != "true" ]]; then
        enable_args+=("--agent" "claude-code")
      fi
      if [[ "$has_strategy" != "true" ]]; then
        enable_args+=("--strategy" "manual-commit")
      fi

      if [[ ${#enable_args[@]} -gt 0 ]]; then
        entire enable "${enable_args[@]}"
      else
        entire enable
      fi
      ;;
    rewind-list)
      entire rewind --list
      ;;
    *)
      cd "$cwd"
      echo "❌ unsupported command: $COMMAND"
      exit 1
      ;;
  esac

  cd "$cwd"
}

case "$COMMAND" in
  status|enable|rewind-list)
    ensure_entire
    parse_target_and_extra "$@"
    resolve_project
    run_for_project
    ;;
  -h|--help|help|"")
    usage
    ;;
  *)
    usage
    exit 1
    ;;
esac

path: scripts/find_handler.py
content: #!/usr/bin/env python3
"""Osori find command handler."""

import os
import shutil
import subprocess
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import (
    filter_projects,
    load_registry,
    normalize_root_key,
    registry_projects,
    registry_roots,
    resolve_alias,
    search_paths_for_discovery,
)


def within_any(path, roots):
    rp = os.path.realpath(path)
    for root in roots:
        rr = os.path.realpath(root)
        try:
            if os.path.commonpath([rp, rr]) == rr:
                return True
        except Exception:
            continue
    return False


name = os.environ["OSORI_NAME"].strip()
root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
resolved_name = resolve_alias(name, res.registry)
query = resolved_name.lower()
projects = filter_projects(registry_projects(res.registry), root_key=root_filter)
root_key = normalize_root_key(root_filter)
roots_meta = registry_roots(res.registry)
root_keys = [r.get("key", "default") for r in roots_meta]

if resolved_name != name:
    print(f"ℹ️ alias resolved: {name} -> {resolved_name}")

# 1) Registry lookup (root-prioritized when root_filter is set)
for p in projects:
    pname = p.get('name', '')
    if query in pname.lower():
        print(f"📁 *{pname}*")
        print(f"📍 {p.get('path', '-')}")
        if p.get('repo'):
            print(f"🌐 {p.get('repo')}")
        if p.get('lang') and p.get('lang') != 'unknown':
            print(f"🔤 {p.get('lang')}")
        print(f"🧭 root: {p.get('root', 'default')}")
        if res.migrated:
            notes = '; '.join(res.migration_notes)
            print(f"\nℹ️ Migrated registry: {notes}")
            if res.backup_path:
                print(f"ℹ️ Migration backup: {res.backup_path}")
        raise SystemExit(0)

# Build prioritized search paths from roots[].paths + OSORI_SEARCH_PATHS
search_paths = search_paths_for_discovery(
    res.registry,
    root_key=root_filter,
    env_paths=os.environ.get('OSORI_SEARCH_PATHS', ''),
)

# 2) Spotlight (macOS)
if shutil.which('mdfind'):
    r = subprocess.run(['mdfind', f'kMDItemFSName == "{resolved_name}"'], capture_output=True, text=True)
    lines = [line for line in r.stdout.strip().split('\n') if line.strip()]
    if lines:
        if root_key:
            root_only_paths = search_paths_for_discovery(res.registry, root_key=root_filter, env_paths='')
            if root_only_paths:
                lines = [line for line in lines if within_any(line, root_only_paths)]
        if lines:
            print("🔍 *Found via Spotlight:*")
            for p in lines[:3]:
                print(f"📍 {p}")
            raise SystemExit(0)

# 3) find fallback (root paths first)
for sp in search_paths:
    if not os.path.exists(sp):
        continue
    r = subprocess.run(
        ['find', sp, '-maxdepth', '4', '-type', 'd', '-name', f'*{name}*'],
        capture_output=True, text=True, timeout=10,
    )
    lines = [line for line in r.stdout.strip().split('\n') if line.strip()]
    if lines:
        print("🔍 *Found via search:*")
        for p in lines[:3]:
            print(f"📍 {p}")
        raise SystemExit(0)

if root_key and root_key not in root_keys:
    print(f"ℹ️ Unknown root '{root_key}'. Available roots: {', '.join(root_keys)}")

if not search_paths:
    print("ℹ️ Tip: set roots[].paths in registry or OSORI_SEARCH_PATHS for fallback discovery")

print(f"❌ Project '{name}' not found.")


path: scripts/github_cache.py
content: #!/usr/bin/env python3
"""GitHub open-count cache helpers for Osori.

Cache schema:
{
  "version": 1,
  "updatedAt": <unix-seconds>,
  "entries": {
    "open_count:pr:owner/repo": {"value": 3, "fetchedAt": 1700000000},
    ...
  }
}
"""

from __future__ import annotations

import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any, Callable, Dict, Tuple

CACHE_VERSION = 1
DEFAULT_CACHE_PATH = os.path.expanduser("~/.openclaw/osori-cache.json")


def _now() -> int:
    return int(time.time())


def _default_cache() -> Dict[str, Any]:
    return {
        "version": CACHE_VERSION,
        "updatedAt": _now(),
        "entries": {},
    }


def _normalize_cache(payload: Any) -> Dict[str, Any]:
    if not isinstance(payload, dict):
        return _default_cache()

    entries = payload.get("entries", {})
    if not isinstance(entries, dict):
        entries = {}

    normalized_entries: Dict[str, Dict[str, int]] = {}
    for k, v in entries.items():
        if not isinstance(v, dict):
            continue
        try:
            value = int(v.get("value"))
            fetched_at = int(v.get("fetchedAt"))
        except Exception:
            continue
        normalized_entries[str(k)] = {"value": value, "fetchedAt": fetched_at}

    return {
        "version": CACHE_VERSION,
        "updatedAt": int(payload.get("updatedAt", _now())) if isinstance(payload.get("updatedAt"), int) else _now(),
        "entries": normalized_entries,
    }


def load_cache(cache_path: str | None = None) -> Dict[str, Any]:
    path = Path(cache_path or DEFAULT_CACHE_PATH).expanduser()
    if not path.exists():
        return _default_cache()

    try:
        with path.open("r", encoding="utf-8") as f:
            payload = json.load(f)
        return _normalize_cache(payload)
    except Exception:
        return _default_cache()


def save_cache(cache: Dict[str, Any], cache_path: str | None = None) -> bool:
    path = Path(cache_path or DEFAULT_CACHE_PATH).expanduser()
    path.parent.mkdir(parents=True, exist_ok=True)

    cache_doc = _normalize_cache(cache)
    cache_doc["updatedAt"] = _now()

    tmp_path = None
    try:
        fd, tmp_path = tempfile.mkstemp(prefix=".osori-cache-", suffix=".tmp", dir=str(path.parent))
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(cache_doc, f, indent=2, ensure_ascii=False)
            f.write("\n")
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_path, path)
        tmp_path = None
        return True
    except Exception:
        return False
    finally:
        if tmp_path and os.path.exists(tmp_path):
            try:
                os.unlink(tmp_path)
            except Exception:
                pass


def _cache_key(kind: str, repo: str) -> str:
    return f"open_count:{kind}:{repo}"


def get_open_count(
    kind: str,
    repo: str,
    ttl_seconds: int = 600,
    cache_path: str | None = None,
    fetch_open_count: Callable[[], int | None] | None = None,
) -> Tuple[str, str]:
    """Return (value, cache_state).

    cache_state: hit|miss|stale-fallback|fetch-failed|skip
    """
    if not repo:
        return "n/a", "skip"

    ttl = int(ttl_seconds)
    cache = load_cache(cache_path)
    key = _cache_key(kind, repo)
    entry = cache.get("entries", {}).get(key)

    now = _now()
    if isinstance(entry, dict):
        try:
            age = now - int(entry.get("fetchedAt", 0))
            # ttl<=0 means always refresh (never hit cache)
            if ttl > 0 and age <= ttl:
                return str(int(entry["value"])), "hit"
        except Exception:
            pass

    if fetch_open_count is None:
        if isinstance(entry, dict) and "value" in entry:
            try:
                return str(int(entry["value"])), "stale-fallback"
            except Exception:
                pass
        return "n/a", "fetch-failed"

    value = fetch_open_count()
    if value is None:
        if isinstance(entry, dict) and "value" in entry:
            try:
                return str(int(entry["value"])), "stale-fallback"
            except Exception:
                pass
        return "n/a", "fetch-failed"

    try:
        value_int = int(value)
    except Exception:
        return "n/a", "fetch-failed"

    cache_entries = cache.setdefault("entries", {})
    if not isinstance(cache_entries, dict):
        cache_entries = {}
        cache["entries"] = cache_entries

    cache_entries[key] = {"value": value_int, "fetchedAt": now}
    save_cache(cache, cache_path)
    return str(value_int), "miss"


# ── High-level helper ────────────────────────────────

def gh_count(
    kind: str,
    repo: str,
    ttl_seconds: int = 600,
    cache_path: str | None = None,
) -> str:
    """Return open PR or issue count as a string (or 'n/a').

    Uses gh CLI to fetch and the TTL cache to avoid API spam.
    """
    import json
    import shutil
    import subprocess

    if not repo or shutil.which("gh") is None:
        return "n/a"

    def _run(cmd, timeout=12):
        try:
            p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            return p.returncode, p.stdout.strip()
        except Exception:
            return 1, ""

    def fetch():
        rc, out = _run(["gh", kind, "list", "-R", repo, "--state", "open", "--json", "number", "--limit", "200"])
        if rc != 0 or not out:
            return None
        try:
            return len(json.loads(out))
        except Exception:
            return None

    value, _state = get_open_count(
        kind=kind,
        repo=repo,
        ttl_seconds=ttl_seconds,
        cache_path=cache_path or DEFAULT_CACHE_PATH,
        fetch_open_count=fetch,
    )
    return value


path: scripts/list_handler.py
content: #!/usr/bin/env python3
"""Osori list command handler."""

import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import filter_projects, load_registry, registry_projects, registry_roots

root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
all_projects = registry_projects(res.registry)
projects = filter_projects(all_projects, root_key=root_filter)
roots = registry_roots(res.registry)
root_keys = [r.get('key', 'default') for r in roots]

if not projects:
    if root_filter:
        print(f"📂 No projects in root '{root_filter}'.")
        print(f"Available roots: {', '.join(root_keys)}")
    else:
        print("📂 No projects registered yet.")
    raise SystemExit(0)

header = f"📋 *{len(projects)} Projects*"
meta = f"(schema={res.registry.get('schema')} v{res.registry.get('version')})"
root_meta = f" [root={root_filter}]" if root_filter else ""
print(f"{header}{root_meta} {meta}\n")

for p in projects[:20]:
    name = p.get('name', '-')
    lang = p.get('lang', '-')
    root = p.get('root', 'default')
    tags = ', '.join(p.get('tags', [])) or '-'
    repo = p.get('repo', '')

    repo_str = f" | 🌐 {repo}" if repo else ""
    print(f"• *{name}* | {lang} | {root} | {tags}{repo_str}")

if len(projects) > 20:
    print(f"\n... and {len(projects) - 20} more")

if res.migrated:
    notes = '; '.join(res.migration_notes)
    print(f"\nℹ️ Migrated registry: {notes}")
    if res.backup_path:
        print(f"ℹ️ Migration backup: {res.backup_path}")


path: scripts/project-fingerprints.sh
content: #!/usr/bin/env bash
# Show project fingerprints (repo/commit/PR/issue) from osori registry
# Usage:
#   project-fingerprints.sh [name-query]
#   project-fingerprints.sh --root <root-key> [name-query]

set -euo pipefail

QUERY=""
ROOT_FILTER=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --root)
      ROOT_FILTER="${2:-}"
      shift 2
      ;;
    -h|--help)
      echo "Usage: $0 [name-query] [--root <root-key>]"
      exit 0
      ;;
    *)
      if [[ -z "$QUERY" ]]; then
        QUERY="$1"
      fi
      shift
      ;;
  esac
done

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

OSORI_SCRIPT_DIR="$SCRIPT_DIR" \
OSORI_REG="$REGISTRY_FILE" \
OSORI_QUERY="$QUERY" \
OSORI_ROOT_FILTER="$ROOT_FILTER" \
python3 << 'PYEOF'
import os
import subprocess
import sys
from typing import List

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from github_cache import DEFAULT_CACHE_PATH, gh_count
from registry_lib import filter_projects, load_registry, parse_repo_from_remote, registry_projects, resolve_alias


def run(cmd: List[str], timeout: int = 8):
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout.strip(), p.stderr.strip()
    except Exception as e:
        return 1, "", str(e)


CACHE_PATH = os.environ.get("OSORI_CACHE_FILE", DEFAULT_CACHE_PATH)
try:
    CACHE_TTL = int(os.environ.get("OSORI_CACHE_TTL", "600"))
except Exception:
    CACHE_TTL = 600


query = os.environ.get("OSORI_QUERY", "").strip()
root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()
reg_file = os.environ["OSORI_REG"]

loaded = load_registry(reg_file, auto_migrate=True, make_backup_on_migrate=True)
resolved_query = resolve_alias(query, loaded.registry) if query else query
projects = filter_projects(registry_projects(loaded.registry), root_key=root_filter, name_query=resolved_query)

if not projects:
    where = []
    if root_filter:
        where.append(f"root={root_filter}")
    if query:
        where.append(f"query={query}")
    suffix = f" ({', '.join(where)})" if where else ""
    print(f"📂 No matching projects{suffix}.")
    raise SystemExit(0)

if query and resolved_query != query:
    print(f"ℹ️ alias resolved: {query} -> {resolved_query}")
    print()

if loaded.migrated:
    notes = "; ".join(loaded.migration_notes)
    print(f"ℹ️ Registry migrated: {notes}")
    if loaded.backup_path:
        print(f"ℹ️ Migration backup: {loaded.backup_path}")
    print()

headline = f"🧬 Project fingerprints ({len(projects)})"
if root_filter:
    headline += f" [root={root_filter}]"
print(f"{headline}\n")

for p in projects:
    name = p.get("name", "-")
    path = p.get("path", "")
    root = p.get("root", "default")

    print(f"• {name} [{root}]")

    if not path or not os.path.exists(path):
        print(f"  - path: {path or '-'}")
        print("  - remote: n/a")
        print("  - last commit: n/a")
        print("  - open PRs: n/a")
        print("  - open issues: n/a")
        print()
        continue

    rc, remote, _ = run(["git", "-C", path, "remote", "get-url", "origin"])
    if rc != 0:
        remote = ""

    repo = p.get("repo", "") or parse_repo_from_remote(remote)

    rc, last, _ = run(["git", "-C", path, "log", "-1", "--format=%H|%cI"])
    if rc == 0 and "|" in last:
        commit_hash, commit_date = last.split("|", 1)
        commit_str = f"{commit_hash} ({commit_date})"
    else:
        commit_str = "n/a"

    pr_count = gh_count("pr", repo, ttl_seconds=CACHE_TTL, cache_path=CACHE_PATH)
    issue_count = gh_count("issue", repo, ttl_seconds=CACHE_TTL, cache_path=CACHE_PATH)

    print(f"  - path: {path}")
    print(f"  - remote: {remote or 'n/a'}")
    print(f"  - last commit: {commit_str}")
    print(f"  - open PRs: {pr_count}")
    print(f"  - open issues: {issue_count}")
    print()
PYEOF

path: scripts/registry_lib.py
content: #!/usr/bin/env python3
"""Registry utilities for Osori.

Supports:
- Legacy array registry format (v0)
- Versioned object registry format (v1+)
- Automatic migration to current schema/version
- Atomic writes with backup + rollback fallback
"""

from __future__ import annotations

import copy
import datetime as dt
import json
import os
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Tuple

REGISTRY_SCHEMA = "osori.registry"
REGISTRY_VERSION = 2


@dataclass
class LoadResult:
    registry: Dict[str, Any]
    migrated: bool
    migration_notes: List[str]
    backup_path: str | None = None


def _utc_now_iso() -> str:
    return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _local_today() -> str:
    return dt.date.today().isoformat()


def _default_roots() -> List[Dict[str, Any]]:
    return [{"key": "default", "label": "Default", "paths": []}]


def _empty_registry() -> Dict[str, Any]:
    return {
        "schema": REGISTRY_SCHEMA,
        "version": REGISTRY_VERSION,
        "updatedAt": _utc_now_iso(),
        "roots": _default_roots(),
        "projects": [],
        "aliases": {},
    }


def _normalize_tags(value: Any) -> List[str]:
    if isinstance(value, list):
        tags = [str(v).strip() for v in value if str(v).strip()]
        # stable de-dup
        deduped: List[str] = []
        seen = set()
        for t in tags:
            if t in seen:
                continue
            seen.add(t)
            deduped.append(t)
        return deduped
    if isinstance(value, str) and value.strip():
        return [value.strip()]
    return []


def normalize_project(item: Any) -> Dict[str, Any] | None:
    if not isinstance(item, dict):
        return None

    name = str(item.get("name", "")).strip()
    path = str(item.get("path", "")).strip()
    if not name or not path:
        return None

    proj = {
        "name": name,
        "path": path,
        "repo": str(item.get("repo", "") or ""),
        "lang": str(item.get("lang", "unknown") or "unknown"),
        "tags": _normalize_tags(item.get("tags", [])),
        "description": str(item.get("description", "") or ""),
        "addedAt": str(item.get("addedAt") or _local_today()),
        "root": str(item.get("root", "default") or "default"),
        "favorite": bool(item.get("favorite", False)),
    }
    return proj


def _normalize_roots(value: Any) -> List[Dict[str, Any]]:
    roots: List[Dict[str, Any]] = []
    seen = set()

    if isinstance(value, list):
        for r in value:
            if not isinstance(r, dict):
                continue
            key = str(r.get("key", "")).strip() or "default"
            if key in seen:
                continue
            seen.add(key)
            label = str(r.get("label", "")).strip() or key.title()
            paths_raw = r.get("paths", [])
            paths = [str(p) for p in paths_raw if isinstance(p, str)] if isinstance(paths_raw, list) else []
            roots.append({"key": key, "label": label, "paths": paths})

    if "default" not in seen:
        roots.insert(0, {"key": "default", "label": "Default", "paths": []})
    return roots


def _migrate_legacy_array(payload: List[Any]) -> Tuple[Dict[str, Any], List[str]]:
    reg = _empty_registry()
    projects = []
    for item in payload:
        p = normalize_project(item)
        if p:
            projects.append(p)
    reg["projects"] = projects
    reg["updatedAt"] = _utc_now_iso()
    return reg, ["legacy-array -> v2"]


def _migrate_object(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
    notes: List[str] = []

    reg = copy.deepcopy(payload)

    # schema/version defaults
    original_schema = reg.get("schema")
    if original_schema != REGISTRY_SCHEMA:
        notes.append(f"schema normalized ({original_schema!r} -> {REGISTRY_SCHEMA!r})")
        reg["schema"] = REGISTRY_SCHEMA

    try:
        version = int(reg.get("version", 1))
    except Exception:
        version = 1

    # normalize projects container
    raw_projects = reg.get("projects")
    if raw_projects is None:
        raw_projects = []
    if not isinstance(raw_projects, list):
        raw_projects = []
        notes.append("projects field normalized to []")

    projects = []
    for item in raw_projects:
        p = normalize_project(item)
        if p:
            projects.append(p)

    reg["projects"] = projects

    # normalize roots
    reg["roots"] = _normalize_roots(reg.get("roots"))
    root_keys = {r["key"] for r in reg["roots"]}
    for p in reg["projects"]:
        proj_root = p.get("root", "default") or "default"
        if proj_root not in root_keys:
            reg["roots"].append({"key": proj_root, "label": proj_root.title(), "paths": []})
            root_keys.add(proj_root)

    # normalize aliases
    reg["aliases"] = registry_aliases(reg)

    if version < REGISTRY_VERSION:
        notes.append(f"version migration v{version} -> v{REGISTRY_VERSION}")
        reg["version"] = REGISTRY_VERSION
    else:
        reg["version"] = version

    reg["updatedAt"] = _utc_now_iso()
    return reg, notes


def migrate_registry_payload(payload: Any) -> Tuple[Dict[str, Any], List[str]]:
    if isinstance(payload, list):
        return _migrate_legacy_array(payload)
    if isinstance(payload, dict):
        return _migrate_object(payload)
    raise ValueError("Unsupported registry format (expected list or object)")


def _timestamp_suffix() -> str:
    return dt.datetime.now().strftime("%Y%m%d-%H%M%S")


def save_registry(path: str, registry: Dict[str, Any], make_backup: bool = True) -> str | None:
    p = Path(path).expanduser()
    p.parent.mkdir(parents=True, exist_ok=True)

    reg_copy = copy.deepcopy(registry)
    reg_copy.setdefault("schema", REGISTRY_SCHEMA)
    reg_copy.setdefault("version", REGISTRY_VERSION)
    reg_copy.setdefault("roots", _default_roots())
    reg_copy.setdefault("projects", [])
    reg_copy.setdefault("aliases", {})
    reg_copy["updatedAt"] = _utc_now_iso()

    backup_path: str | None = None
    tmp_path = None

    try:
        if make_backup and p.exists():
            backup_path = f"{p}.bak-{_timestamp_suffix()}"
            shutil.copy2(p, backup_path)

        fd, tmp_path = tempfile.mkstemp(prefix=".osori-registry-", suffix=".tmp", dir=str(p.parent))
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(reg_copy, f, indent=2, ensure_ascii=False)
            f.write("\n")
            f.flush()
            os.fsync(f.fileno())

        os.replace(tmp_path, p)
        tmp_path = None
        return backup_path

    except Exception:
        # rollback fallback
        if tmp_path and os.path.exists(tmp_path):
            os.unlink(tmp_path)
        if backup_path and os.path.exists(backup_path):
            shutil.copy2(backup_path, p)
        raise


def _handle_corruption(path: str) -> Tuple[Dict[str, Any], List[str], str | None]:
    p = Path(path).expanduser()
    notes = ["registry corrupted: initialized empty v2"]
    backup_path = None

    if p.exists():
        broken = f"{p}.broken-{_timestamp_suffix()}"
        shutil.copy2(p, broken)
        notes.append(f"corrupted backup saved: {broken}")

    reg = _empty_registry()
    backup_path = save_registry(str(p), reg, make_backup=True)
    return reg, notes, backup_path


def load_registry(path: str, auto_migrate: bool = True, make_backup_on_migrate: bool = True) -> LoadResult:
    p = Path(path).expanduser()

    if not p.exists():
        reg = _empty_registry()
        save_registry(str(p), reg, make_backup=False)
        return LoadResult(registry=reg, migrated=True, migration_notes=["registry created (v2)"], backup_path=None)

    try:
        with p.open("r", encoding="utf-8") as f:
            payload = json.load(f)
    except Exception:
        reg, notes, backup = _handle_corruption(str(p))
        return LoadResult(registry=reg, migrated=True, migration_notes=notes, backup_path=backup)

    migrated_registry, notes = migrate_registry_payload(payload)

    migrated = bool(notes)
    backup_path = None

    if auto_migrate and migrated:
        backup_path = save_registry(str(p), migrated_registry, make_backup=make_backup_on_migrate)

    return LoadResult(
        registry=migrated_registry,
        migrated=migrated,
        migration_notes=notes,
        backup_path=backup_path,
    )


def registry_projects(registry: Dict[str, Any]) -> List[Dict[str, Any]]:
    projects = registry.get("projects", [])
    if not isinstance(projects, list):
        return []
    return projects


def registry_roots(registry: Dict[str, Any]) -> List[Dict[str, Any]]:
    return _normalize_roots(registry.get("roots", []))


def normalize_root_key(root_key: str | None) -> str | None:
    if root_key is None:
        return None
    key = str(root_key).strip()
    if not key:
        return None
    if key in {"all", "*"}:
        return None
    return key


def _normalize_alias_key(alias: str | None) -> str:
    if alias is None:
        return ""
    return str(alias).strip().lower()


def registry_aliases(registry: Dict[str, Any]) -> Dict[str, str]:
    raw = registry.get("aliases", {})
    if not isinstance(raw, dict):
        return {}

    out: Dict[str, str] = {}
    for k, v in raw.items():
        key = _normalize_alias_key(str(k))
        if not key:
            continue
        if not isinstance(v, str):
            continue
        target = v.strip()
        if not target:
            continue
        out[key] = target
    return out


def set_registry_aliases(registry: Dict[str, Any], aliases: Dict[str, str]) -> None:
    normalized = {}
    for k, v in aliases.items():
        key = _normalize_alias_key(k)
        target = str(v).strip()
        if not key or not target:
            continue
        normalized[key] = target
    registry["aliases"] = normalized
    registry["updatedAt"] = _utc_now_iso()


def resolve_alias(name_or_alias: str, registry: Dict[str, Any]) -> str:
    aliases = registry_aliases(registry)
    key = _normalize_alias_key(name_or_alias)
    if key in aliases:
        return aliases[key]
    return name_or_alias


def parse_env_search_paths(paths_env: str | None) -> List[str]:
    if not paths_env:
        return []
    return [p.strip() for p in str(paths_env).split(":") if p.strip()]


def search_paths_for_discovery(registry: Dict[str, Any], root_key: str | None = None, env_paths: str | None = None) -> List[str]:
    rk = normalize_root_key(root_key)
    roots = registry_roots(registry)

    ordered: List[str] = []

    if rk:
        for r in roots:
            if r.get("key") == rk:
                ordered.extend([p for p in r.get("paths", []) if isinstance(p, str) and p.strip()])
                break
    else:
        for r in roots:
            ordered.extend([p for p in r.get("paths", []) if isinstance(p, str) and p.strip()])

    ordered.extend(parse_env_search_paths(env_paths))

    # stable de-dup
    deduped: List[str] = []
    seen = set()
    for p in ordered:
        if p in seen:
            continue
        seen.add(p)
        deduped.append(p)

    return deduped


def filter_projects(projects: List[Dict[str, Any]], root_key: str | None = None, name_query: str | None = None) -> List[Dict[str, Any]]:
    selected = projects

    rk = normalize_root_key(root_key)
    if rk:
        selected = [p for p in selected if str(p.get("root", "default")) == rk]

    q = (name_query or "").strip().lower()
    if q:
        selected = [p for p in selected if q in str(p.get("name", "")).lower()]

    return selected


def ensure_root_exists(registry: Dict[str, Any], root_key: str | None) -> str:
    key = normalize_root_key(root_key) or "default"
    roots = registry_roots(registry)
    existing = {r.get("key") for r in roots}
    if key not in existing:
        roots.append({"key": key, "label": key.title(), "paths": []})
    registry["roots"] = roots
    return key


def set_registry_projects(registry: Dict[str, Any], projects: List[Dict[str, Any]]) -> None:
    registry["projects"] = projects
    registry["updatedAt"] = _utc_now_iso()


def parse_repo_from_remote(remote_url: str) -> str:
    # Supports both https://github.com/owner/repo(.git) and [REDACTED_SECRET]:owner/repo.git
    if not remote_url:
        return ""
    url = remote_url.strip()

    marker = "github.com"
    if marker not in url:
        return ""

    # Normalize separator after domain to '/'
    after = url.split(marker, 1)[1]
    after = after.lstrip(":/")
    if after.endswith(".git"):
        after = after[:-4]

    parts = after.split("/")
    if len(parts) < 2:
        return ""

    owner, repo = parts[0], parts[1]
    if not owner or not repo:
        return ""
    return f"{owner}/{repo}"


path: scripts/remove_handler.py
content: #!/usr/bin/env python3
"""Osori remove command handler."""

import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects, save_registry, set_registry_projects

name = os.environ["OSORI_NAME"]
res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
projects = registry_projects(registry)

original_len = len(projects)
projects = [p for p in projects if p.get('name', '').lower() != name.lower()]

if len(projects) == original_len:
    print(f"❌ Project '{name}' not found.")
    raise SystemExit(1)

set_registry_projects(registry, projects)
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

print(f"✅ Removed: {name}")
if backup_path:
    print(f"Backup: {backup_path}")


path: scripts/root-manager.sh
content: #!/usr/bin/env bash
# Manage registry roots (list/add/path-add/path-remove/set-label/remove)
# Usage:
#   root-manager.sh list
#   root-manager.sh add <root-key> [label]
#   root-manager.sh path-add <root-key> <path>
#   root-manager.sh path-remove <root-key> <path>
#   root-manager.sh set-label <root-key> <label>
#   root-manager.sh remove <root-key> [--reassign <target-root>] [--force]

set -euo pipefail

COMMAND="${1:-list}"
if [[ $# -gt 0 ]]; then
  shift
fi

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

usage() {
  cat << 'EOF'
Usage:
  root-manager.sh list
  root-manager.sh add <root-key> [label]
  root-manager.sh path-add <root-key> <path>
  root-manager.sh path-remove <root-key> <path>
  root-manager.sh set-label <root-key> <label>
  root-manager.sh remove <root-key> [--reassign <target-root>] [--force]
EOF
}

case "$COMMAND" in
  list)
    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects, registry_roots

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)
projects = registry_projects(registry)

counts = {}
for p in projects:
    rk = str(p.get("root", "default") or "default")
    counts[rk] = counts.get(rk, 0) + 1

print(f"🗂️ Roots ({len(roots)})")
print()
for r in roots:
    key = r.get("key", "default")
    label = r.get("label", key.title())
    paths = [p for p in r.get("paths", []) if isinstance(p, str)]
    print(f"• {key} (label: {label})")
    print(f"  - projects: {counts.get(key, 0)}")
    print(f"  - paths: {len(paths)}")
    for p in paths:
        print(f"    - {p}")
    print()

if res.migrated:
    notes = "; ".join(res.migration_notes)
    print(f"ℹ️ Migrated registry: {notes}")
    if res.backup_path:
        print(f"ℹ️ Migration backup: {res.backup_path}")
PYEOF
    ;;

  add)
    ROOT_KEY="${1:-}"
    LABEL="${2:-}"
    [[ -z "$ROOT_KEY" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ROOT_KEY="$ROOT_KEY" OSORI_LABEL="$LABEL" python3 << 'PYEOF'
import os
import re
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_roots, save_registry

key = os.environ["OSORI_ROOT_KEY"].strip()
label = os.environ.get("OSORI_LABEL", "").strip()

if not re.match(r"^[A-Za-z0-9_-]+$", key):
    print("❌ root key must match [A-Za-z0-9_-]+")
    raise SystemExit(1)

if key in {"all", "*"}:
    print("❌ root key cannot be 'all' or '*'")
    raise SystemExit(1)

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)

found = False
for r in roots:
    if r.get("key") == key:
        found = True
        if label:
            r["label"] = label
        break

if not found:
    roots.append({"key": key, "label": label or key.title(), "paths": []})

registry["roots"] = roots
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

if found:
    print(f"✅ Updated root: {key}")
else:
    print(f"✅ Added root: {key}")

if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  path-add)
    ROOT_KEY="${1:-}"
    ROOT_PATH="${2:-}"
    [[ -z "$ROOT_KEY" || -z "$ROOT_PATH" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ROOT_KEY="$ROOT_KEY" OSORI_ROOT_PATH="$ROOT_PATH" python3 << 'PYEOF'
import os
import re
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_roots, save_registry

key = os.environ["OSORI_ROOT_KEY"].strip()
raw_path = os.environ["OSORI_ROOT_PATH"].strip()
path = os.path.realpath(os.path.abspath(os.path.expanduser(raw_path)))

if not re.match(r"^[A-Za-z0-9_-]+$", key):
    print("❌ root key must match [A-Za-z0-9_-]+")
    raise SystemExit(1)

if not os.path.isdir(path):
    print(f"❌ directory not found: {path}")
    raise SystemExit(1)

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)

target = None
for r in roots:
    if r.get("key") == key:
        target = r
        break

if target is None:
    target = {"key": key, "label": key.title(), "paths": []}
    roots.append(target)

paths = [os.path.realpath(os.path.abspath(os.path.expanduser(p))) for p in target.get("paths", []) if isinstance(p, str)]
if path not in paths:
    paths.append(path)

target["paths"] = paths
registry["roots"] = roots
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

print(f"✅ Added path to root '{key}': {path}")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  path-remove)
    ROOT_KEY="${1:-}"
    ROOT_PATH="${2:-}"
    [[ -z "$ROOT_KEY" || -z "$ROOT_PATH" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ROOT_KEY="$ROOT_KEY" OSORI_ROOT_PATH="$ROOT_PATH" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_roots, save_registry

key = os.environ["OSORI_ROOT_KEY"].strip()
path = os.path.realpath(os.path.abspath(os.path.expanduser(os.environ["OSORI_ROOT_PATH"].strip())))

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)

target = None
for r in roots:
    if r.get("key") == key:
        target = r
        break

if target is None:
    print(f"❌ root not found: {key}")
    raise SystemExit(1)

paths = [os.path.realpath(os.path.abspath(os.path.expanduser(p))) for p in target.get("paths", []) if isinstance(p, str)]
new_paths = [p for p in paths if p != path]

if len(new_paths) == len(paths):
    print(f"ℹ️ path not found in root '{key}': {path}")
    raise SystemExit(0)

target["paths"] = new_paths
registry["roots"] = roots
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

print(f"✅ Removed path from root '{key}': {path}")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  set-label)
    ROOT_KEY="${1:-}"
    LABEL="${2:-}"
    [[ -z "$ROOT_KEY" || -z "$LABEL" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" OSORI_ROOT_KEY="$ROOT_KEY" OSORI_LABEL="$LABEL" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_roots, save_registry

key = os.environ["OSORI_ROOT_KEY"].strip()
label = os.environ["OSORI_LABEL"].strip()

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)

for r in roots:
    if r.get("key") == key:
        r["label"] = label
        registry["roots"] = roots
        backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)
        print(f"✅ Updated label for root '{key}': {label}")
        if backup_path:
            print(f"Backup: {backup_path}")
        raise SystemExit(0)

print(f"❌ root not found: {key}")
raise SystemExit(1)
PYEOF
    ;;

  remove)
    ROOT_KEY="${1:-}"
    shift || true
    REASSIGN_TARGET=""
    FORCE="false"

    while [[ $# -gt 0 ]]; do
      case "$1" in
        --reassign)
          REASSIGN_TARGET="${2:-}"
          shift 2
          ;;
        --force)
          FORCE="true"
          shift
          ;;
        *)
          shift
          ;;
      esac
    done

    [[ -z "$ROOT_KEY" ]] && { usage; exit 1; }

    OSORI_SCRIPT_DIR="$SCRIPT_DIR" \
    OSORI_REG="$REGISTRY_FILE" \
    OSORI_ROOT_KEY="$ROOT_KEY" \
    OSORI_REASSIGN_TARGET="$REASSIGN_TARGET" \
    OSORI_FORCE="$FORCE" \
    python3 << 'PYEOF'
import os
import re
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects, registry_roots, save_registry, set_registry_projects

root_key = os.environ["OSORI_ROOT_KEY"].strip()
reassign = os.environ.get("OSORI_REASSIGN_TARGET", "").strip()
force = os.environ.get("OSORI_FORCE", "false").lower() == "true"

if not re.match(r"^[A-Za-z0-9_-]+$", root_key):
    print("❌ root key must match [A-Za-z0-9_-]+")
    raise SystemExit(1)

if root_key == "default":
    print("❌ cannot remove protected root: default")
    raise SystemExit(1)

if reassign and reassign == root_key:
    print("❌ --reassign target must be different from removed root")
    raise SystemExit(1)

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
registry = res.registry
roots = registry_roots(registry)
projects = registry_projects(registry)

root_keys = [r.get("key", "default") for r in roots]
if root_key not in root_keys:
    print(f"❌ root not found: {root_key}")
    raise SystemExit(1)

affected = [p for p in projects if str(p.get("root", "default") or "default") == root_key]
affected_count = len(affected)

if affected_count > 0 and not reassign and not force:
    print(f"❌ root '{root_key}' has {affected_count} project(s); use --reassign <target-root> or --force")
    raise SystemExit(1)

target_root = None
if reassign:
    if reassign == "default":
        target_root = "default"
    else:
        if reassign not in root_keys:
            print(f"❌ reassign target root not found: {reassign}")
            raise SystemExit(1)
        target_root = reassign
elif force and affected_count > 0:
    target_root = "default"

if target_root:
    for p in projects:
        if str(p.get("root", "default") or "default") == root_key:
            p["root"] = target_root

new_roots = [r for r in roots if r.get("key") != root_key]
registry["roots"] = new_roots
set_registry_projects(registry, projects)
backup_path = save_registry(os.environ["OSORI_REG"], registry, make_backup=True)

if target_root:
    if force and not reassign:
        print(f"⚠️ force mode: reassigned {affected_count} project(s) to 'default' before removing '{root_key}'")
    else:
        print(f"✅ reassigned {affected_count} project(s) from '{root_key}' to '{target_root}'")

print(f"✅ removed root: {root_key}")
if backup_path:
    print(f"Backup: {backup_path}")
PYEOF
    ;;

  -h|--help|help)
    usage
    ;;

  *)
    echo "Unknown command: $COMMAND"
    usage
    exit 1
    ;;
esac

path: scripts/scan-projects.sh
content: #!/usr/bin/env bash
# Scan a directory for git repos and add them to the registry
# Usage: scan-projects.sh <root-dir> [--depth N]

set -euo pipefail

ROOT="${1:-.}"
DEPTH=3

shift || true
while [[ $# -gt 0 ]]; do
  case "$1" in
    --depth) DEPTH="${2:-3}"; shift 2 ;;
    *) shift ;;
  esac
done

if [[ ! -d "$ROOT" ]]; then
  echo "Directory not found: $ROOT"
  exit 1
fi

REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"
ROOT_KEY="${OSORI_ROOT_KEY:-default}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

TMPFILE="$(mktemp)"
echo "[]" > "$TMPFILE"
trap 'rm -f "$TMPFILE"' EXIT

# Load existing names (with auto-migration)
EXISTING_NAMES=$(OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" python3 << 'PYEOF'
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import load_registry, registry_projects

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
projects = registry_projects(res.registry)
for p in projects:
    name = p.get("name", "")
    if name:
        print(name)

if res.migrated:
    notes = "; ".join(res.migration_notes)
    print(f"Migrated registry on load: {notes}")
    if res.backup_path:
        print(f"Migration backup: {res.backup_path}")
PYEOF
)

while IFS= read -r gitdir; do
  dir="$(dirname "$gitdir")"
  name="$(basename "$dir")"

  # Skip if already registered
  if echo "$EXISTING_NAMES" | grep -qx "$name"; then
    continue
  fi

  # Detect remote/repo
  remote=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
  repo=""
  if [[ -n "$remote" ]]; then
    repo=$(OSORI_REMOTE="$remote" PYTHONPATH="$SCRIPT_DIR" python3 - << 'PYEOF'
import os
from registry_lib import parse_repo_from_remote
print(parse_repo_from_remote(os.environ.get("OSORI_REMOTE", "")))
PYEOF
)
  fi

  # Detect language
  lang="unknown"
  if [[ -f "$dir/Package.swift" ]]; then lang="swift"
  elif [[ -f "$dir/package.json" ]]; then lang="typescript"
  elif [[ -f "$dir/Cargo.toml" ]]; then lang="rust"
  elif [[ -f "$dir/go.mod" ]]; then lang="go"
  elif [[ -f "$dir/pyproject.toml" ]] || [[ -f "$dir/setup.py" ]]; then lang="python"
  elif [[ -f "$dir/Gemfile" ]]; then lang="ruby"
  fi

  # Detect description safely
  desc=""
  if [[ -f "$dir/package.json" ]]; then
    desc=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('description',''))" < "$dir/package.json" 2>/dev/null || true)
  fi

  parent="$(basename "$(dirname "$dir")")"
  today=$(date +%Y-%m-%d)

  OSORI_TMPFILE="$TMPFILE" \
  OSORI_NAME="$name" \
  OSORI_PATH="$dir" \
  OSORI_REPO="$repo" \
  OSORI_LANG="$lang" \
  OSORI_TAG="$parent" \
  OSORI_DESC="$desc" \
  OSORI_TODAY="$today" \
  OSORI_ROOT_KEY="$ROOT_KEY" \
  python3 << 'PYEOF'
import json
import os

path = os.environ["OSORI_TMPFILE"]
with open(path, encoding="utf-8") as f:
    entries = json.load(f)

entries.append({
    "name": os.environ["OSORI_NAME"],
    "path": os.environ["OSORI_PATH"],
    "repo": os.environ["OSORI_REPO"],
    "lang": os.environ["OSORI_LANG"],
    "tags": [os.environ["OSORI_TAG"]] if os.environ.get("OSORI_TAG") else [],
    "description": os.environ["OSORI_DESC"],
    "addedAt": os.environ["OSORI_TODAY"],
    "root": os.environ.get("OSORI_ROOT_KEY", "default") or "default",
})

with open(path, "w", encoding="utf-8") as f:
    json.dump(entries, f, ensure_ascii=False)
PYEOF

done < <(find "$ROOT" -maxdepth "$DEPTH" -name '.git' -type d 2>/dev/null)

# Merge with existing registry
OSORI_SCRIPT_DIR="$SCRIPT_DIR" \
OSORI_REG="$REGISTRY_FILE" \
OSORI_TMPFILE="$TMPFILE" \
python3 << 'PYEOF'
import json
import os
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import ensure_root_exists, load_registry, registry_projects, save_registry, set_registry_projects

reg_file = os.environ["OSORI_REG"]
with open(os.environ["OSORI_TMPFILE"], encoding="utf-8") as f:
    new_entries = json.load(f)

loaded = load_registry(reg_file, auto_migrate=True, make_backup_on_migrate=True)
registry = loaded.registry
projects = registry_projects(registry)
existing_names = {p.get("name") for p in projects}

added = 0
for e in new_entries:
    if e.get("name") in existing_names:
        continue
    e["root"] = ensure_root_exists(registry, e.get("root", "default"))
    projects.append(e)
    existing_names.add(e.get("name"))
    added += 1

set_registry_projects(registry, projects)
backup_path = save_registry(reg_file, registry, make_backup=True)

if loaded.migrated:
    notes = "; ".join(loaded.migration_notes)
    print(f"Migrated registry on load: {notes}")
    if loaded.backup_path:
        print(f"Migration backup: {loaded.backup_path}")

if backup_path:
    print(f"Backup: {backup_path}")

print(f"Added {added} projects. Total: {len(projects)}")
PYEOF

path: scripts/status_handler.py
content: #!/usr/bin/env python3
"""Osori status command handler."""

import os
import subprocess
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from registry_lib import filter_projects, load_registry, registry_projects

root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
projects = filter_projects(registry_projects(res.registry), root_key=root_filter)

clean = modified = missing = 0

for p in projects:
    path = p.get('path', '')
    if not path or not os.path.exists(path):
        missing += 1
        continue

    try:
        result = subprocess.run(
            ['git', '-C', path, 'status', '--short'],
            capture_output=True, text=True, timeout=3,
        )
        if result.stdout.strip():
            modified += 1
        else:
            clean += 1
    except Exception:
        missing += 1

root_meta = f" [root={root_filter}]" if root_filter else ""
print(f"📊 *Project Status*{root_meta}\n")
print(f"✅ Clean: {clean}")
print(f"📝 Modified: {modified}")
print(f"⚠️ Missing: {missing}")
print(f"📁 Total: {len(projects)}")
print(f"🧾 Registry: schema={res.registry.get('schema')} v{res.registry.get('version')}")

if res.migrated:
    notes = '; '.join(res.migration_notes)
    print(f"\nℹ️ Migrated registry: {notes}")
    if res.backup_path:
        print(f"ℹ️ Migration backup: {res.backup_path}")


path: scripts/switch_handler.py
content: #!/usr/bin/env python3
"""Osori switch command handler."""

import os
import subprocess
import sys

sys.path.insert(0, os.environ["OSORI_SCRIPT_DIR"])
from github_cache import DEFAULT_CACHE_PATH, gh_count
from registry_lib import (
    filter_projects,
    load_registry,
    normalize_root_key,
    parse_repo_from_remote,
    registry_projects,
    registry_roots,
    resolve_alias,
    search_paths_for_discovery,
)


def run(cmd, timeout=8):
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout.strip(), p.stderr.strip()
    except Exception as e:
        return 1, "", str(e)


try:
    CACHE_TTL = int(os.environ.get("OSORI_CACHE_TTL", "600"))
except Exception:
    CACHE_TTL = 600
CACHE_PATH = os.environ.get("OSORI_CACHE_FILE", DEFAULT_CACHE_PATH)


def git_last_commit(path):
    rc_iso, iso, _ = run(["git", "-C", path, "log", "-1", "--format=%cI"])
    rc_ts, ts, _ = run(["git", "-C", path, "log", "-1", "--format=%ct"])
    if rc_iso != 0 or not iso:
        iso = "n/a"
    if rc_ts != 0 or not ts:
        return iso, 0
    try:
        return iso, int(ts)
    except Exception:
        return iso, 0


def git_dirty(path):
    rc, out, _ = run(["git", "-C", path, "status", "--short"])
    if rc != 0:
        return "n/a"
    return "dirty" if bool(out.strip()) else "clean"


name_raw = os.environ["OSORI_NAME"].strip()
root_filter = os.environ.get("OSORI_ROOT_FILTER", "").strip()
index_arg = os.environ.get("OSORI_SWITCH_INDEX", "").strip()
root_key = normalize_root_key(root_filter)

res = load_registry(os.environ["OSORI_REG"], auto_migrate=True, make_backup_on_migrate=True)
resolved_name = resolve_alias(name_raw, res.registry)
name = resolved_name.lower()
projects = filter_projects(registry_projects(res.registry), root_key=root_filter)
roots = [r.get("key", "default") for r in registry_roots(res.registry)]

if resolved_name != name_raw:
    print(f"ℹ️ alias resolved: {name_raw} -> {resolved_name}")

candidates = []
for p in projects:
    pname = str(p.get("name", ""))
    if name not in pname.lower():
        continue

    ppath = str(p.get("path", ""))
    exists = bool(ppath) and os.path.exists(ppath)
    commit_iso = "n/a"
    commit_ts = 0
    dirty = "n/a"

    if exists:
        commit_iso, commit_ts = git_last_commit(ppath)
        dirty = git_dirty(ppath)

    candidates.append({
        "project": p,
        "name": pname,
        "root": str(p.get("root", "default") or "default"),
        "path": ppath,
        "exists": exists,
        "repo": str(p.get("repo", "") or ""),
        "commit_iso": commit_iso,
        "commit_ts": commit_ts,
        "dirty": dirty,
        "score": 0,
    })

if not candidates:
    if root_key:
        print(f"❌ Project '{name_raw}' not found in root '{root_key}' registry.")
    else:
        print(f"❌ Project '{name_raw}' not found in registry.")

    if root_key and root_key not in roots:
        print(f"ℹ️ Available roots: {', '.join(roots)}")

    # Suggest possible paths using prioritized discovery paths
    search_paths = search_paths_for_discovery(res.registry, root_key=root_filter, env_paths=os.environ.get('OSORI_SEARCH_PATHS', ''))
    hints = []
    for sp in search_paths:
        if not os.path.exists(sp):
            continue
        rc, out, _ = run(['find', sp, '-maxdepth', '4', '-type', 'd', '-name', f'*{name_raw}*'], timeout=10)
        if rc != 0 or not out:
            continue
        for line in out.split('\n'):
            line = line.strip()
            if line:
                hints.append(line)
            if len(hints) >= 3:
                break
        if hints:
            break

    if hints:
        print("\n🔍 Possible paths:")
        for h in hints:
            print(f"  - {h}")
        print("\nUse /add <path> then /switch again.")

    raise SystemExit(1)

# Score policy (roadmap fixed)
max_commit_ts = max((c["commit_ts"] for c in candidates), default=0)
for c in candidates:
    score = 0
    nlow = c["name"].lower()

    if root_key and c["root"] == root_key:
        score += 50
    if nlow == name:
        score += 30
    if nlow.startswith(name):
        score += 20
    if max_commit_ts > 0 and c["commit_ts"] == max_commit_ts:
        score += 10
    if not c["exists"]:
        score -= 10
    if not c["repo"]:
        score -= 5

    c["score"] = score

candidates.sort(key=lambda c: (-c["score"], -c["commit_ts"], c["name"].lower()))

if len(candidates) > 1:
    print(f"🔎 Multiple matches ({len(candidates)}):")
    for idx, c in enumerate(candidates, 1):
        print(
            f"  {idx}. {c['name']} [{c['root']}] | score={c['score']} | "
            f"dirty={c['dirty']} | last={c['commit_iso']}"
        )
    print()

selected = None
if index_arg:
    try:
        index_val = int(index_arg)
    except Exception:
        print(f"❌ invalid --index value: {index_arg!r}")
        raise SystemExit(1)

    if index_val < 1 or index_val > len(candidates):
        print(f"❌ --index out of range: {index_val} (1..{len(candidates)})")
        raise SystemExit(1)

    selected = candidates[index_val - 1]
    if len(candidates) > 1:
        print(f"✅ Selected candidate #{index_val} explicitly.\n")
else:
    selected = candidates[0]
    if len(candidates) > 1:
        print("🤖 Auto-selected #1 by score policy. Use --index <n> to choose explicitly.\n")

target = selected["project"]
path = selected["path"]
if not path or not os.path.exists(path):
    print(f"⚠️ Path does not exist: {path}")
    raise SystemExit(1)

print(f"📁 *{target.get('name')}*")
print(f"📍 {path}")
print(f"🧭 root: {target.get('root', 'default')}")

# git status
_, status_out, _ = run(['git', '-C', path, 'status', '--short'])
if status_out:
    print("\n📝 Changes:")
    for line in status_out.split('\n')[:5]:
        print(f"  {line}")
else:
    print("\n✅ Clean working tree")

# branch
_, branch, _ = run(['git', '-C', path, 'branch', '--show-current'])
print(f"\n🌿 Branch: {branch or '-'}")

# recent commits
_, log, _ = run(['git', '-C', path, 'log', '--oneline', '-3'])
if log:
    print("\n💬 Recent commits:")
    for line in log.split('\n'):
        print(f"  {line}")

# fingerprints
_, remote, _ = run(['git', '-C', path, 'remote', 'get-url', 'origin'])
repo = target.get('repo', '') or parse_repo_from_remote(remote)
_, last, _ = run(['git', '-C', path, 'log', '-1', '--format=%H|%cI'])
if '|' in last:
    commit_hash, commit_date = last.split('|', 1)
    last_commit = f"{commit_hash} ({commit_date})"
else:
    last_commit = 'n/a'

print("\n🧬 Fingerprint:")
print(f"  - remote: {remote or 'n/a'}")
print(f"  - last commit: {last_commit}")
print(f"  - open PRs: {gh_count('pr', repo, ttl_seconds=CACHE_TTL, cache_path=CACHE_PATH)}")
print(f"  - open issues: {gh_count('issue', repo, ttl_seconds=CACHE_TTL, cache_path=CACHE_PATH)}")

if res.migrated:
    notes = '; '.join(res.migration_notes)
    print(f"\nℹ️ Migrated registry: {notes}")
    if res.backup_path:
        print(f"ℹ️ Migration backup: {res.backup_path}")


path: scripts/telegram-commands.sh
content: #!/usr/bin/env bash
# Telegram bot command handler for osori
# Usage: telegram-commands.sh <command> [args...]

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_FILE="${OSORI_REGISTRY:-$HOME/.openclaw/osori.json}"

# Note: each cmd_* handler calls load_registry() internally.
# No upfront load needed — avoids duplicate I/O and backup creation.

show_help() {
    cat << 'EOF'
🦦 *Osori Bot Commands*

/list [root] — Show all projects (optionally filter by root)
/status [root] — Check project statuses (optionally filter by root)
/find <name> [root|--root <root>] — Find a project path (optional root scope)
/switch <name> [root|--root <root>] [--index <n>] — Switch to project & load context (multi-match selection)
/fingerprints [name] [--root <root>] — Show repo/commit/PR/issue fingerprints
/doctor [--fix] [--dry-run] [--yes] [--json] — Registry health check (preview-first)
/list-roots — List roots, labels, paths, project counts
/root-add <key> [label] — Add/update root
/root-path-add <key> <path> — Add discovery path to root
/root-path-remove <key> <path> — Remove discovery path from root
/root-set-label <key> <label> — Update root label
/root-remove <key> [--reassign <target>] [--force] — Safely remove root
/alias-add <alias> <project> — Add alias for project
/alias-remove <alias> — Remove alias
/favorites — Show favorite projects
/favorite-add <project> — Mark project as favorite
/favorite-remove <project> — Unmark favorite
/entire-status <project> [root|--root <root>] — Show Entire status in project
/entire-enable <project> [root|--root <root>] [--agent <name>] [--strategy <name>] — Enable Entire in project
/entire-rewind-list <project> [root|--root <root>] — List Entire rewind points (JSON)
/add <path> — Add project to registry
/remove <name> — Remove project from registry
/scan <path> [root] — Scan directory for projects (optional root key)
/help — Show this help

*Examples:*
`/list work`
`/status personal`
`/find agent-avengers work`
`/switch Tesella --root personal`
`/switch Tesella --root personal --index 1`
`/fingerprints Tesella --root personal`
`/doctor --fix`
`/list-roots`
`/root-add work Work`
`/root-path-add work /path/to/workspace`
`/root-remove work --reassign default`
`/alias-add rh RunnersHeart`
`/favorites`
`/entire-status osori`
`/entire-enable osori --agent claude-code --strategy manual-commit`
`/scan /path/to/workspace work`
EOF
}

cmd_list() {
    local root_filter="${1:-}"
    OSORI_ROOT_FILTER="$root_filter" OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" \
      python3 "$SCRIPT_DIR/list_handler.py"
}

cmd_status() {
    local root_filter="${1:-}"
    OSORI_ROOT_FILTER="$root_filter" OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" \
      python3 "$SCRIPT_DIR/status_handler.py"
}

cmd_find() {
    local name=""
    local root_filter=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --root)
                root_filter="${2:-}"
                shift 2
                ;;
            *)
                if [[ -z "$name" ]]; then
                    name="$1"
                elif [[ -z "$root_filter" ]]; then
                    root_filter="$1"
                fi
                shift
                ;;
        esac
    done

    [[ -z "$name" ]] && { echo "❌ Usage: /find <project-name> [root|--root <root>]"; exit 1; }

    OSORI_NAME="$name" OSORI_ROOT_FILTER="$root_filter" OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" \
      python3 "$SCRIPT_DIR/find_handler.py"
}

cmd_switch() {
    local name=""
    local root_filter=""
    local index_arg=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --root)
                root_filter="${2:-}"
                shift 2
                ;;
            --index)
                index_arg="${2:-}"
                shift 2
                ;;
            *)
                if [[ -z "$name" ]]; then
                    name="$1"
                elif [[ -z "$root_filter" ]]; then
                    root_filter="$1"
                fi
                shift
                ;;
        esac
    done

    [[ -z "$name" ]] && { echo "❌ Usage: /switch <project-name> [root|--root <root>] [--index <n>]"; exit 1; }

    OSORI_NAME="$name" OSORI_ROOT_FILTER="$root_filter" OSORI_SWITCH_INDEX="$index_arg" OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" \
      python3 "$SCRIPT_DIR/switch_handler.py"
}

cmd_fingerprints() {
    bash "$SCRIPT_DIR/project-fingerprints.sh" "$@"
}

cmd_add() {
    local path="${1:-}"
    [[ -z "$path" ]] && { echo "❌ Usage: /add <path>"; exit 1; }
    [[ ! -d "$path" ]] && { echo "❌ Directory not found: $path"; exit 1; }

    bash "$SCRIPT_DIR/add-project.sh" "$path"
}

cmd_remove() {
    local name="${1:-}"
    [[ -z "$name" ]] && { echo "❌ Usage: /remove <project-name>"; exit 1; }

    OSORI_NAME="$name" OSORI_SCRIPT_DIR="$SCRIPT_DIR" OSORI_REG="$REGISTRY_FILE" \
      python3 "$SCRIPT_DIR/remove_handler.py"
}

cmd_scan() {
    local path=""
    local root_key=""
    local depth="2"

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --depth)
                depth="${2:-2}"
                shift 2
                ;;
            *)
                if [[ -z "$path" ]]; then
                    path="$1"
                elif [[ -z "$root_key" ]]; then
                    root_key="$1"
                fi
                shift
                ;;
        esac
    done

    local default_scan_root="${OSORI_SCAN_DEFAULT:-${OSORI_SEARCH_PATHS%%:*}}"
    if [[ -z "$path" ]]; then
      path="${default_scan_root:-.}"
    fi

    [[ ! -d "$path" ]] && { echo "❌ Directory not found: $path"; exit 1; }

    if [[ -n "$root_key" ]]; then
      echo "🔍 *Scanning for git repositories...* (root=$root_key, depth=$depth)"
      OSORI_ROOT_KEY="$root_key" bash "$SCRIPT_DIR/scan-projects.sh" "$path" --depth "$depth"
    else
      echo "🔍 *Scanning for git repositories...* (depth=$depth)"
      bash "$SCRIPT_DIR/scan-projects.sh" "$path" --depth "$depth"
    fi
}

cmd_doctor() {
    bash "$SCRIPT_DIR/doctor.sh" "$@"
}

cmd_list_roots() {
    bash "$SCRIPT_DIR/root-manager.sh" list
}

cmd_root_add() {
    local key="${1:-}"
    shift || true
    local label="${*:-}"

    [[ -z "$key" ]] && { echo "❌ Usage: /root-add <key> [label]"; exit 1; }

    if [[ -n "$label" ]]; then
      bash "$SCRIPT_DIR/root-manager.sh" add "$key" "$label"
    else
      bash "$SCRIPT_DIR/root-manager.sh" add "$key"
    fi
}

cmd_root_path_add() {
    local key="${1:-}"
    local path="${2:-}"
    [[ -z "$key" || -z "$path" ]] && { echo "❌ Usage: /root-path-add <key> <path>"; exit 1; }
    bash "$SCRIPT_DIR/root-manager.sh" path-add "$key" "$path"
}

cmd_root_path_remove() {
    local key="${1:-}"
    local path="${2:-}"
    [[ -z "$key" || -z "$path" ]] && { echo "❌ Usage: /root-path-remove <key> <path>"; exit 1; }
    bash "$SCRIPT_DIR/root-manager.sh" path-remove "$key" "$path"
}

cmd_root_set_label() {
    local key="${1:-}"
    shift || true
    local label="${*:-}"

    [[ -z "$key" || -z "$label" ]] && { echo "❌ Usage: /root-set-label <key> <label>"; exit 1; }
    bash "$SCRIPT_DIR/root-manager.sh" set-label "$key" "$label"
}

cmd_root_remove() {
    local key="${1:-}"
    shift || true

    [[ -z "$key" ]] && { echo "❌ Usage: /root-remove <key> [--reassign <target>] [--force]"; exit 1; }

    bash "$SCRIPT_DIR/root-manager.sh" remove "$key" "$@"
}

cmd_alias_add() {
    local alias_key="${1:-}"
    local project="${2:-}"
    [[ -z "$alias_key" || -z "$project" ]] && { echo "❌ Usage: /alias-add <alias> <project>"; exit 1; }
    bash "$SCRIPT_DIR/alias-favorite-manager.sh" alias-add "$alias_key" "$project"
}

cmd_alias_remove() {
    local alias_key="${1:-}"
    [[ -z "$alias_key" ]] && { echo "❌ Usage: /alias-remove <alias>"; exit 1; }
    bash "$SCRIPT_DIR/alias-favorite-manager.sh" alias-remove "$alias_key"
}

cmd_favorites() {
    bash "$SCRIPT_DIR/alias-favorite-manager.sh" favorites
}

cmd_favorite_add() {
    local project="${1:-}"
    [[ -z "$project" ]] && { echo "❌ Usage: /favorite-add <project>"; exit 1; }
    bash "$SCRIPT_DIR/alias-favorite-manager.sh" favorite-add "$project"
}

cmd_favorite_remove() {
    local project="${1:-}"
    [[ -z "$project" ]] && { echo "❌ Usage: /favorite-remove <project>"; exit 1; }
    bash "$SCRIPT_DIR/alias-favorite-manager.sh" favorite-remove "$project"
}

cmd_entire_status() {
    [[ $# -lt 1 ]] && { echo "❌ Usage: /entire-status <project> [root|--root <root>]"; exit 1; }
    bash "$SCRIPT_DIR/entire-manager.sh" status "$@"
}

cmd_entire_enable() {
    [[ $# -lt 1 ]] && { echo "❌ Usage: /entire-enable <project> [root|--root <root>] [--agent <name>] [--strategy <name>]"; exit 1; }
    bash "$SCRIPT_DIR/entire-manager.sh" enable "$@"
}

cmd_entire_rewind_list() {
    [[ $# -lt 1 ]] && { echo "❌ Usage: /entire-rewind-list <project> [root|--root <root>]"; exit 1; }
    bash "$SCRIPT_DIR/entire-manager.sh" rewind-list "$@"
}

# Main dispatch
command="${1:-help}"
if [[ $# -gt 0 ]]; then
  shift
fi

case "$command" in
    list) cmd_list "${1:-}" ;;
    status) cmd_status "${1:-}" ;;
    find) cmd_find "$@" ;;
    switch) cmd_switch "$@" ;;
    fingerprints) cmd_fingerprints "$@" ;;
    doctor) cmd_doctor "$@" ;;
    list-roots|roots) cmd_list_roots ;;
    root-add) cmd_root_add "$@" ;;
    root-path-add) cmd_root_path_add "$@" ;;
    root-path-remove) cmd_root_path_remove "$@" ;;
    root-set-label) cmd_root_set_label "$@" ;;
    root-remove) cmd_root_remove "$@" ;;
    alias-add) cmd_alias_add "${1:-}" "${2:-}" ;;
    alias-remove) cmd_alias_remove "${1:-}" ;;
    favorites) cmd_favorites ;;
    favorite-add) cmd_favorite_add "${1:-}" ;;
    favorite-remove) cmd_favorite_remove "${1:-}" ;;
    entire-status) cmd_entire_status "$@" ;;
    entire-enable) cmd_entire_enable "$@" ;;
    entire-rewind-list) cmd_entire_rewind_list "$@" ;;
    add) cmd_add "${1:-}" ;;
    remove) cmd_remove "${1:-}" ;;
    scan) cmd_scan "$@" ;;
    help|--help|-h) show_help ;;
    *) echo "❌ Unknown command: $command"; show_help; exit 1 ;;
esac

path: tests/run_tests.sh
content: #!/usr/bin/env bash
# Test runner for osori scripts
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
PASSED=0
FAILED=0
ERRORS=()

RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

assert_eq() {
  local desc="$1" expected="$2" actual="$3"
  if [[ "$expected" == "$actual" ]]; then
    echo -e "  ${GREEN}✓${NC} $desc"; PASSED=$((PASSED + 1))
  else
    echo -e "  ${RED}✗${NC} $desc"; echo "    expected: $expected"; echo "    actual:   $actual"
    FAILED=$((FAILED + 1)); ERRORS+=("$desc")
  fi
}

assert_contains() {
  local desc="$1" haystack="$2" needle="$3"
  if [[ "$haystack" == *"$needle"* ]]; then
    echo -e "  ${GREEN}✓${NC} $desc"; PASSED=$((PASSED + 1))
  else
    echo -e "  ${RED}✗${NC} $desc"; echo "    expected to contain: $needle"; echo "    actual: $haystack"
    FAILED=$((FAILED + 1)); ERRORS+=("$desc")
  fi
}

assert_not_contains() {
  local desc="$1" haystack="$2" needle="$3"
  if [[ "$haystack" != *"$needle"* ]]; then
    echo -e "  ${GREEN}✓${NC} $desc"; PASSED=$((PASSED + 1))
  else
    echo -e "  ${RED}✗${NC} $desc"; echo "    expected NOT to contain: $needle"
    FAILED=$((FAILED + 1)); ERRORS+=("$desc")
  fi
}

assert_file_exists() {
  local desc="$1" file="$2"
  if [[ -f "$file" ]]; then
    echo -e "  ${GREEN}✓${NC} $desc"; PASSED=$((PASSED + 1))
  else
    echo -e "  ${RED}✗${NC} $desc"; echo "    file not found: $file"
    FAILED=$((FAILED + 1)); ERRORS+=("$desc")
  fi
}

project_count() {
  local file="$1"
  python3 - "$file" << 'PYEOF'
import json, sys
with open(sys.argv[1], encoding='utf-8') as f:
    data = json.load(f)
if isinstance(data, list):
    print(len(data))
elif isinstance(data, dict):
    print(len(data.get('projects', [])))
else:
    print(0)
PYEOF
}

setup_test() {
  TEST_TMP="$(mktemp -d)"
  mkdir -p "$TEST_TMP/fake-project"
  git -C "$TEST_TMP/fake-project" init -q 2>/dev/null || true
  git -C "$TEST_TMP/fake-project" config user.email "[REDACTED_SECRET]"
  git -C "$TEST_TMP/fake-project" config user.name "osori-test"
  echo "hello" > "$TEST_TMP/fake-project/README.md"
  git -C "$TEST_TMP/fake-project" add README.md >/dev/null 2>&1
  git -C "$TEST_TMP/fake-project" commit -m "init" >/dev/null 2>&1 || true
  export OSORI_REGISTRY="$TEST_TMP/osori.json"
}

teardown_test() {
  rm -rf "$TEST_TMP"
  unset OSORI_REGISTRY
  unset OSORI_ROOT_KEY
}

# ─── TESTS ───

echo "=== test_add_project_schema_v2 ==="
setup_test
output=$(bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "myproject" 2>&1)
assert_contains "add prints Added" "$output" "Added"
assert_file_exists "registry created" "$TEST_TMP/osori.json"
content=$(cat "$TEST_TMP/osori.json")
assert_contains "registry has schema field" "$content" '"schema": "osori.registry"'
assert_contains "registry has version field" "$content" '"version": 2'
assert_contains "registry has projects container" "$content" '"projects": ['
assert_contains "registry has root field" "$content" '"root": "default"'
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "project count is one" "1" "$count"
teardown_test

echo ""
echo "=== test_add_duplicate ==="
setup_test
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "duptest" >/dev/null 2>&1
output=$(bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "duptest" 2>&1)
assert_contains "duplicate detected" "$output" "Already registered"
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "only one entry" "1" "$count"
teardown_test

echo ""
echo "=== test_scan_projects ==="
setup_test
for name in proj-a proj-b proj-c; do
  mkdir -p "$TEST_TMP/repos/$name"
  git -C "$TEST_TMP/repos/$name" init -q 2>/dev/null
  git -C "$TEST_TMP/repos/$name" config user.email "[REDACTED_SECRET]"
  git -C "$TEST_TMP/repos/$name" config user.name "osori-test"
  echo "$name" > "$TEST_TMP/repos/$name/README.md"
  git -C "$TEST_TMP/repos/$name" add README.md >/dev/null 2>&1
  git -C "$TEST_TMP/repos/$name" commit -m "init" >/dev/null 2>&1 || true
done
output=$(bash "$PROJECT_ROOT/scripts/scan-projects.sh" "$TEST_TMP/repos" --depth 2 2>&1)
assert_contains "scan reports additions" "$output" "Added 3"
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "three projects registered" "3" "$count"
teardown_test

echo ""
echo "=== test_legacy_registry_auto_migration ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {
    "name": "legacy-proj",
    "path": "/tmp/legacy",
    "repo": "",
    "lang": "unknown",
    "tags": [],
    "description": "",
    "addedAt": "2026-02-10"
  }
]
JSONEOF
output=$(bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "new-proj" 2>&1)
assert_contains "legacy migration notice" "$output" "Migrated registry on load"
content=$(cat "$TEST_TMP/osori.json")
assert_contains "migrated schema present" "$content" '"schema": "osori.registry"'
assert_contains "legacy preserved" "$content" '"legacy-proj"'
assert_contains "new project preserved" "$content" '"new-proj"'
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "two projects after migration+add" "2" "$count"
backup_count=$(find "$TEST_TMP" -name 'osori.json.bak-*' | wc -l | tr -d ' ')
assert_eq "backup created on migration" "1" "$backup_count"
teardown_test

echo ""
echo "=== test_corrupted_registry_recovery ==="
setup_test
printf '{ this is broken json ' > "$TEST_TMP/osori.json"
output=$(bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "recover-proj" 2>&1)
assert_contains "corruption recovery notice" "$output" "registry corrupted"
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "one project after recovery" "1" "$count"
broken_count=$(find "$TEST_TMP" -name 'osori.json.broken-*' | wc -l | tr -d ' ')
assert_eq "broken backup created" "1" "$broken_count"
teardown_test

echo ""
echo "=== test_doctor_detects_basic_issues ==="
setup_test
# create missing path project + invalid repo format
cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [{"key": "default", "label": "Default", "paths": []}],
  "projects": [
    {
      "name": "bad-path",
      "path": "$TEST_TMP/no-such-dir",
      "repo": "invalid repo format",
      "lang": "unknown",
      "tags": [],
      "description": "",
      "addedAt": "2026-02-16",
      "root": "default"
    }
  ]
}
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" 2>&1 || true)
assert_contains "doctor detects missing path" "$out" "project.path_missing"
assert_contains "doctor detects invalid repo" "$out" "project.repo_invalid_format"
teardown_test

echo ""
echo "=== test_doctor_detects_duplicates ==="
setup_test
cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [{"key": "default", "label": "Default", "paths": []}],
  "projects": [
    {"name": "dup", "path": "$TEST_TMP/fake-project", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"},
    {"name": "dup", "path": "$TEST_TMP/fake-project", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"}
  ]
}
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" 2>&1 || true)
assert_contains "doctor duplicate name" "$out" "project.duplicate_name"
assert_contains "doctor duplicate path" "$out" "project.duplicate_path"
teardown_test

echo ""
echo "=== test_doctor_detects_root_reference_missing ==="
setup_test
cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [{"key": "default", "label": "Default", "paths": []}],
  "projects": [
    {"name": "x", "path": "$TEST_TMP/fake-project", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "work"}
  ]
}
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" 2>&1 || true)
assert_contains "doctor root mismatch" "$out" "project.root_reference_missing"
teardown_test

echo ""
echo "=== test_doctor_json_output ==="
setup_test
out_json=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --json 2>&1)
assert_contains "doctor json has status" "$out_json" '"status"'
assert_contains "doctor json has counts" "$out_json" '"counts"'
assert_contains "doctor json has findings" "$out_json" '"findings"'
teardown_test

echo ""
echo "=== test_doctor_fix_legacy_migration ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {
    "name": "legacy-proj",
    "path": "/tmp/legacy",
    "repo": "",
    "lang": "unknown",
    "tags": [],
    "description": "",
    "addedAt": "2026-02-10"
  }
]
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix 2>&1)
assert_contains "doctor fix migration info" "$out" "fix.migration_applied"
content=$(cat "$TEST_TMP/osori.json")
assert_contains "doctor fix migrated schema" "$content" '"schema": "osori.registry"'
teardown_test

echo ""
echo "=== test_doctor_fix_dedupe ==="
setup_test
cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [{"key": "default", "label": "Default", "paths": []}],
  "projects": [
    {"name": "dup", "path": "$TEST_TMP/fake-project", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"},
    {"name": "dup", "path": "$TEST_TMP/fake-project", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"}
  ]
}
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix 2>&1)
assert_contains "doctor fix dedupe message" "$out" "fix.dedupe_applied"
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "doctor fix removed duplicate" "1" "$count"
teardown_test

echo ""
echo "=== test_doctor_fix_corrupted_registry ==="
setup_test
printf '{ broken json' > "$TEST_TMP/osori.json"
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix 2>&1)
assert_contains "doctor corrupted detected" "$out" "registry.corrupted"
count=$(project_count "$TEST_TMP/osori.json")
assert_eq "doctor fix reinitialized registry" "0" "$count"
broken_count=$(find "$TEST_TMP" -name 'osori.json.broken-*' | wc -l | tr -d ' ')
assert_eq "doctor fix saved broken backup" "1" "$broken_count"
teardown_test

echo ""
echo "=== test_telegram_doctor_command ==="
setup_test
doctor_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" doctor --json 2>&1)
assert_contains "telegram doctor returns json" "$doctor_out" '"status"'
teardown_test

echo ""
echo "=== test_doctor_preview_first_default ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {"name": "prev-proj", "path": "/tmp/prev", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16"}
]
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" 2>&1)
assert_contains "doctor default shows preview" "$out" "Preview only"
assert_contains "doctor default shows plan" "$out" "Fix Plan"
# Verify no changes were applied (file should still be legacy array)
first_char=$(head -c1 "$TEST_TMP/osori.json")
assert_eq "doctor default did not modify file" "[" "$first_char"
teardown_test

echo ""
echo "=== test_doctor_dry_run_never_applies ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {"name": "dry-proj", "path": "/tmp/dry", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16"}
]
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --dry-run 2>&1)
assert_contains "doctor dry-run message" "$out" "Dry-run mode"
assert_contains "doctor dry-run shows plan" "$out" "Fix Plan"
first_char=$(head -c1 "$TEST_TMP/osori.json")
assert_eq "doctor dry-run did not modify file" "[" "$first_char"
teardown_test

echo ""
echo "=== test_doctor_dry_run_overrides_fix ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {"name": "override-proj", "path": "/tmp/override", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16"}
]
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix --dry-run 2>&1)
assert_contains "doctor dry-run overrides fix" "$out" "Dry-run mode"
first_char=$(head -c1 "$TEST_TMP/osori.json")
assert_eq "doctor dry-run+fix did not modify file" "[" "$first_char"
teardown_test

echo ""
echo "=== test_doctor_fix_applies_changes ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {"name": "fix-proj", "path": "/tmp/fix-proj", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16"}
]
JSONEOF
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix 2>&1)
assert_contains "doctor fix applied" "$out" "Fix applied"
content=$(cat "$TEST_TMP/osori.json")
assert_contains "doctor fix migrated" "$content" '"schema": "osori.registry"'
teardown_test

echo ""
echo "=== test_doctor_json_has_plan_and_risk ==="
setup_test
cat > "$TEST_TMP/osori.json" << 'JSONEOF'
[
  {"name": "json-proj", "path": "/tmp/json-proj", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16"}
]
JSONEOF
out_json=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --json 2>&1)
assert_contains "doctor json has plan" "$out_json" '"plan"'
assert_contains "doctor json has riskSummary" "$out_json" '"riskSummary"'
assert_contains "doctor json has dryRun" "$out_json" '"dryRun"'
teardown_test

echo ""
echo "=== test_doctor_risk_levels_in_plan ==="
setup_test
printf '{ broken json' > "$TEST_TMP/osori.json"
out_json=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --json 2>&1)
# Corrupted registry should produce high risk plan
assert_contains "doctor corrupted has high risk" "$out_json" '"high"'
assert_contains "doctor plan has reinitialize" "$out_json" '"reinitialize"'
teardown_test

echo ""
echo "=== test_doctor_high_risk_blocked_without_yes ==="
setup_test
printf '{ broken json' > "$TEST_TMP/osori.json"
out=$(bash "$PROJECT_ROOT/scripts/doctor.sh" --fix 2>&1)
assert_contains "doctor high risk blocked" "$out" "blocked"
teardown_test

echo ""
echo "=== test_fingerprints_view ==="
setup_test
git -C "$TEST_TMP/fake-project" remote add origin "https://github.com/example/osori-test.git"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "finger-proj" >/dev/null 2>&1
output=$(bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" "finger-proj" 2>&1)
assert_contains "fingerprints includes project" "$output" "finger-proj"
assert_contains "fingerprints includes remote" "$output" "https://github.com/example/osori-test.git"
assert_contains "fingerprints includes last commit" "$output" "last commit:"
assert_contains "fingerprints includes PR count field" "$output" "open PRs:"
assert_contains "fingerprints includes issue count field" "$output" "open issues:"
teardown_test

echo ""
echo "=== test_github_cache_ttl ==="
setup_test

# fake gh command to count invocations
mkdir -p "$TEST_TMP/fakebin"
COUNT_FILE="$TEST_TMP/gh-call-count.txt"
echo 0 > "$COUNT_FILE"
cat > "$TEST_TMP/fakebin/gh" << 'SHEOF'
#!/usr/bin/env bash
set -euo pipefail
COUNT_FILE="${OSORI_FAKE_GH_COUNT_FILE:?}"
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
echo $((count+1)) > "$COUNT_FILE"
if [[ "${1:-}" == "pr" && "${2:-}" == "list" ]]; then
  echo '[{"number":1},{"number":2}]'
elif [[ "${1:-}" == "issue" && "${2:-}" == "list" ]]; then
  echo '[{"number":10}]'
else
  echo '[]'
fi
SHEOF
chmod +x "$TEST_TMP/fakebin/gh"

# prepare project with repo slug
mkdir -p "$TEST_TMP/cache-proj"
git -C "$TEST_TMP/cache-proj" init -q 2>/dev/null
git -C "$TEST_TMP/cache-proj" config user.email "[REDACTED_SECRET]"
git -C "$TEST_TMP/cache-proj" config user.name "osori-test"
echo "cache" > "$TEST_TMP/cache-proj/README.md"
git -C "$TEST_TMP/cache-proj" add README.md >/dev/null 2>&1
git -C "$TEST_TMP/cache-proj" commit -m "init" >/dev/null 2>&1 || true
git -C "$TEST_TMP/cache-proj" remote add origin "https://github.com/example/cache-proj.git"

bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/cache-proj" --name "cache-proj" >/dev/null 2>&1

# first run: miss => 2 calls (pr + issue)
PATH="$TEST_TMP/fakebin:$PATH" OSORI_FAKE_GH_COUNT_FILE="$COUNT_FILE" OSORI_CACHE_FILE="$TEST_TMP/osori-cache.json" OSORI_CACHE_TTL=600 \
  bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" cache-proj >/dev/null 2>&1
count1=$(cat "$COUNT_FILE")
assert_eq "cache miss triggers two gh calls" "2" "$count1"

# second run with same TTL: hit => no additional calls
PATH="$TEST_TMP/fakebin:$PATH" OSORI_FAKE_GH_COUNT_FILE="$COUNT_FILE" OSORI_CACHE_FILE="$TEST_TMP/osori-cache.json" OSORI_CACHE_TTL=600 \
  bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" cache-proj >/dev/null 2>&1
count2=$(cat "$COUNT_FILE")
assert_eq "cache hit avoids extra gh calls" "2" "$count2"

# third run with TTL 0: miss => +2 calls
PATH="$TEST_TMP/fakebin:$PATH" OSORI_FAKE_GH_COUNT_FILE="$COUNT_FILE" OSORI_CACHE_FILE="$TEST_TMP/osori-cache.json" OSORI_CACHE_TTL=0 \
  bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" cache-proj >/dev/null 2>&1
count3=$(cat "$COUNT_FILE")
assert_eq "ttl 0 forces refresh calls" "4" "$count3"
teardown_test

echo ""
echo "=== test_root_key_override ==="
setup_test
export OSORI_ROOT_KEY="work"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "rooted" >/dev/null 2>&1
content=$(cat "$TEST_TMP/osori.json")
assert_contains "project root is set from env" "$content" '"root": "work"'
teardown_test

echo ""
echo "=== test_fingerprints_root_filter ==="
setup_test
# work project
export OSORI_ROOT_KEY="work"
git -C "$TEST_TMP/fake-project" remote add origin "https://github.com/example/work-proj.git"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "work-proj" >/dev/null 2>&1

# personal project
mkdir -p "$TEST_TMP/personal-project"
git -C "$TEST_TMP/personal-project" init -q 2>/dev/null
git -C "$TEST_TMP/personal-project" config user.email "[REDACTED_SECRET]"
git -C "$TEST_TMP/personal-project" config user.name "osori-test"
echo "personal" > "$TEST_TMP/personal-project/README.md"
git -C "$TEST_TMP/personal-project" add README.md >/dev/null 2>&1
git -C "$TEST_TMP/personal-project" commit -m "init" >/dev/null 2>&1 || true
export OSORI_ROOT_KEY="personal"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/personal-project" --name "personal-proj" >/dev/null 2>&1

output=$(bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" --root work 2>&1)
assert_contains "root filter headline" "$output" "[root=work]"
assert_contains "work project visible" "$output" "work-proj"
assert_not_contains "personal project hidden" "$output" "personal-proj"
teardown_test

echo ""
echo "=== test_telegram_root_filters ==="
setup_test
export OSORI_ROOT_KEY="work"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "tg-work" >/dev/null 2>&1
mkdir -p "$TEST_TMP/tg-personal"
git -C "$TEST_TMP/tg-personal" init -q 2>/dev/null
git -C "$TEST_TMP/tg-personal" config user.email "[REDACTED_SECRET]"
git -C "$TEST_TMP/tg-personal" config user.name "osori-test"
echo "x" > "$TEST_TMP/tg-personal/README.md"
git -C "$TEST_TMP/tg-personal" add README.md >/dev/null 2>&1
git -C "$TEST_TMP/tg-personal" commit -m "init" >/dev/null 2>&1 || true
export OSORI_ROOT_KEY="personal"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/tg-personal" --name "tg-personal" >/dev/null 2>&1

list_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" list work 2>&1)
assert_contains "telegram list root header" "$list_out" "[root=work]"
assert_contains "telegram list shows work" "$list_out" "tg-work"
assert_not_contains "telegram list hides personal" "$list_out" "tg-personal"

status_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" status work 2>&1)
assert_contains "telegram status root header" "$status_out" "[root=work]"
assert_contains "telegram status total one" "$status_out" "Total: 1"
teardown_test

echo ""
echo "=== test_root_manager_commands ==="
setup_test
mkdir -p "$TEST_TMP/root-work"

list0=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" list 2>&1)
assert_contains "root-manager list shows default" "$list0" "default"

add_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" add work Work 2>&1)
assert_contains "root-manager add root" "$add_out" "Added root: work"

path_add_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" path-add work "$TEST_TMP/root-work" 2>&1)
assert_contains "root-manager add path" "$path_add_out" "Added path to root 'work'"

label_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" set-label work "Work Team" 2>&1)
assert_contains "root-manager set label" "$label_out" "Updated label for root 'work': Work Team"

list1=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" list 2>&1)
assert_contains "root-manager list shows work root" "$list1" "work"
assert_contains "root-manager list shows updated label" "$list1" "Work Team"
assert_contains "root-manager list shows root path" "$list1" "$TEST_TMP/root-work"

path_remove_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" path-remove work "$TEST_TMP/root-work" 2>&1)
assert_contains "root-manager remove path" "$path_remove_out" "Removed path from root 'work'"

list2=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" list 2>&1)
assert_not_contains "root-manager path removed from list" "$list2" "$TEST_TMP/root-work"

remove_default_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" remove default 2>&1 || true)
assert_contains "root-manager blocks default remove" "$remove_default_out" "cannot remove protected root: default"

export OSORI_ROOT_KEY="work"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "work-owned" >/dev/null 2>&1

remove_block_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" remove work 2>&1 || true)
assert_contains "root-manager blocks remove when projects exist" "$remove_block_out" "has 1 project(s)"

remove_reassign_out=$(bash "$PROJECT_ROOT/scripts/root-manager.sh" remove work --reassign default 2>&1)
assert_contains "root-manager reassign then remove" "$remove_reassign_out" "reassigned 1 project(s)"
assert_contains "root-manager removed root after reassign" "$remove_reassign_out" "removed root: work"

post_remove=$(cat "$TEST_TMP/osori.json")
assert_not_contains "root-manager removed root from registry" "$post_remove" '"key": "work"'
assert_contains "root-manager project reassigned to default" "$post_remove" '"root": "default"'
teardown_test

echo ""
echo "=== test_telegram_root_management_commands ==="
setup_test
mkdir -p "$TEST_TMP/tg-root-path"

roots_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" list-roots 2>&1)
assert_contains "telegram list-roots works" "$roots_out" "Roots"

root_add_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-add work Work 2>&1)
assert_contains "telegram root-add works" "$root_add_out" "Added root: work"

root_path_add_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-path-add work "$TEST_TMP/tg-root-path" 2>&1)
assert_contains "telegram root-path-add works" "$root_path_add_out" "Added path to root 'work'"

root_label_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-set-label work "Work Team" 2>&1)
assert_contains "telegram root-set-label works" "$root_label_out" "Updated label for root 'work': Work Team"

roots_after=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" list-roots 2>&1)
assert_contains "telegram list-roots shows work root" "$roots_after" "work"
assert_contains "telegram list-roots shows label" "$roots_after" "Work Team"
assert_contains "telegram list-roots shows path" "$roots_after" "$TEST_TMP/tg-root-path"

root_path_remove_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-path-remove work "$TEST_TMP/tg-root-path" 2>&1)
assert_contains "telegram root-path-remove works" "$root_path_remove_out" "Removed path from root 'work'"

# remove safety via telegram wrapper
export OSORI_ROOT_KEY="work"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "tg-work-owned" >/dev/null 2>&1

root_remove_block=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-remove work 2>&1 || true)
assert_contains "telegram root-remove blocks when projects exist" "$root_remove_block" "has 1 project(s)"

root_remove_force=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" root-remove work --force 2>&1)
assert_contains "telegram root-remove force message" "$root_remove_force" "force mode"
assert_contains "telegram root-remove force removed" "$root_remove_force" "removed root: work"

roots_final=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" list-roots 2>&1)
assert_not_contains "telegram list-roots path removed" "$roots_final" "$TEST_TMP/tg-root-path"
assert_not_contains "telegram list-roots removed root hidden" "$roots_final" "work"
teardown_test

echo ""
echo "=== test_switch_multimatch_auto_and_index ==="
setup_test

mkdir -p "$TEST_TMP/mm-demo" "$TEST_TMP/mm-demo-app"
for p in "$TEST_TMP/mm-demo" "$TEST_TMP/mm-demo-app"; do
  git -C "$p" init -q 2>/dev/null
  git -C "$p" config user.email "[REDACTED_SECRET]"
  git -C "$p" config user.name "osori-test"
  echo "x" > "$p/README.md"
  git -C "$p" add README.md >/dev/null 2>&1
  GIT_AUTHOR_DATE="2026-02-15T10:00:00" GIT_COMMITTER_DATE="2026-02-15T10:00:00" git -C "$p" commit -m "init" >/dev/null 2>&1 || true
done

cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [{"key": "default", "label": "Default", "paths": []}],
  "projects": [
    {"name": "demo", "path": "$TEST_TMP/mm-demo", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"},
    {"name": "demo-app", "path": "$TEST_TMP/mm-demo-app", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"},
    {"name": "demo-missing", "path": "$TEST_TMP/no-such-dir", "repo": "", "lang": "unknown", "tags": [], "description": "", "addedAt": "2026-02-16", "root": "default"}
  ]
}
JSONEOF

auto_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch demo 2>&1)
assert_contains "switch shows multiple candidates" "$auto_out" "Multiple matches (3)"
assert_contains "switch auto-select message" "$auto_out" "Auto-selected #1"
assert_contains "switch auto-select picks exact name" "$auto_out" "📁 *demo*"

index_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch demo --index 2 2>&1)
assert_contains "switch index selection message" "$index_out" "Selected candidate #2"
assert_contains "switch index picks second candidate" "$index_out" "📁 *demo-app*"

bad_index_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch demo --index 9 2>&1 || true)
assert_contains "switch index out-of-range" "$bad_index_out" "--index out of range"
teardown_test

echo ""
echo "=== test_alias_commands_and_resolution ==="
setup_test

# register project
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "RunnersHeart" >/dev/null 2>&1

alias_add_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" alias-add rh RunnersHeart 2>&1)
assert_contains "alias-add works" "$alias_add_out" "alias added: rh -> RunnersHeart"

aliases_out=$(bash "$PROJECT_ROOT/scripts/alias-favorite-manager.sh" aliases 2>&1)
assert_contains "aliases list includes key" "$aliases_out" "rh -> RunnersHeart"

find_alias_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" find rh 2>&1)
assert_contains "find resolves alias" "$find_alias_out" "alias resolved: rh -> RunnersHeart"
assert_contains "find alias returns project" "$find_alias_out" "RunnersHeart"

fp_alias_out=$(bash "$PROJECT_ROOT/scripts/project-fingerprints.sh" rh 2>&1)
assert_contains "fingerprints resolves alias" "$fp_alias_out" "alias resolved: rh -> RunnersHeart"

alias_remove_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" alias-remove rh 2>&1)
assert_contains "alias-remove works" "$alias_remove_out" "alias removed: rh"
teardown_test

echo ""
echo "=== test_favorite_commands ==="
setup_test

bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "FavProj" >/dev/null 2>&1

fav_add_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" favorite-add FavProj 2>&1)
assert_contains "favorite-add works" "$fav_add_out" "favorite added: FavProj"

favs_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" favorites 2>&1)
assert_contains "favorites list shows project" "$favs_out" "FavProj"

fav_remove_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" favorite-remove FavProj 2>&1)
assert_contains "favorite-remove works" "$fav_remove_out" "favorite removed: FavProj"

favs_out2=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" favorites 2>&1)
assert_contains "favorites empty message" "$favs_out2" "No favorite projects"
teardown_test

echo ""
echo "=== test_entire_manager_commands ==="
setup_test

bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "entire-proj" >/dev/null 2>&1
FAKE_BIN="$TEST_TMP/fake-bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/entire" << 'FAKEEOF'
#!/usr/bin/env bash
echo "FAKE_ENTIRE cwd=$(pwd) args=$*"
FAKEEOF
chmod +x "$FAKE_BIN/entire"

ent_status_out=$(PATH="$FAKE_BIN:$PATH" bash "$PROJECT_ROOT/scripts/telegram-commands.sh" entire-status entire-proj 2>&1)
assert_contains "entire-status uses fake cli" "$ent_status_out" "FAKE_ENTIRE"
assert_contains "entire-status runs detailed status" "$ent_status_out" "args=status --detailed"
assert_contains "entire-status runs in project path" "$ent_status_out" "cwd=$TEST_TMP/fake-project"

ent_rewind_out=$(PATH="$FAKE_BIN:$PATH" bash "$PROJECT_ROOT/scripts/telegram-commands.sh" entire-rewind-list entire-proj 2>&1)
assert_contains "entire-rewind-list runs list mode" "$ent_rewind_out" "args=rewind --list"

ent_enable_default_out=$(PATH="$FAKE_BIN:$PATH" bash "$PROJECT_ROOT/scripts/telegram-commands.sh" entire-enable entire-proj 2>&1)
assert_contains "entire-enable default command" "$ent_enable_default_out" "args=enable"
assert_contains "entire-enable defaults agent" "$ent_enable_default_out" "--agent claude-code"
assert_contains "entire-enable defaults strategy" "$ent_enable_default_out" "--strategy manual-commit"

ent_enable_custom_out=$(PATH="$FAKE_BIN:$PATH" bash "$PROJECT_ROOT/scripts/telegram-commands.sh" entire-enable entire-proj --agent gemini --strategy auto-commit --local 2>&1)
assert_contains "entire-enable custom agent forwarded" "$ent_enable_custom_out" "--agent gemini"
assert_contains "entire-enable custom strategy forwarded" "$ent_enable_custom_out" "--strategy auto-commit"
assert_contains "entire-enable custom local flag forwarded" "$ent_enable_custom_out" "--local"
teardown_test

echo ""
echo "=== test_switch_root_filter ==="
setup_test
export OSORI_ROOT_KEY="work"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "switch-work" >/dev/null 2>&1
mkdir -p "$TEST_TMP/switch-personal"
git -C "$TEST_TMP/switch-personal" init -q 2>/dev/null
git -C "$TEST_TMP/switch-personal" config user.email "[REDACTED_SECRET]"
git -C "$TEST_TMP/switch-personal" config user.name "osori-test"
echo "p" > "$TEST_TMP/switch-personal/README.md"
git -C "$TEST_TMP/switch-personal" add README.md >/dev/null 2>&1
git -C "$TEST_TMP/switch-personal" commit -m "init" >/dev/null 2>&1 || true
export OSORI_ROOT_KEY="personal"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/switch-personal" --name "switch-personal" >/dev/null 2>&1

ok_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch switch-work work 2>&1)
assert_contains "switch finds project in matching root" "$ok_out" "switch-work"
assert_contains "switch output root label" "$ok_out" "root: work"

ok_out_flag=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch switch-work --root work 2>&1)
assert_contains "switch supports --root flag" "$ok_out_flag" "switch-work"

bad_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" switch switch-work personal 2>&1 || true)
assert_contains "switch mismatch root not found" "$bad_out" "not found in root 'personal'"
teardown_test

echo ""
echo "=== test_find_uses_registry_root_paths_priority ==="
setup_test
mkdir -p "$TEST_TMP/work-root/priority-proj"
mkdir -p "$TEST_TMP/env-root/priority-proj"

cat > "$TEST_TMP/osori.json" << JSONEOF
{
  "schema": "osori.registry",
  "version": 2,
  "updatedAt": "2026-02-16T00:00:00Z",
  "roots": [
    {"key": "default", "label": "Default", "paths": []},
    {"key": "work", "label": "Work", "paths": ["$TEST_TMP/work-root"]}
  ],
  "projects": []
}
JSONEOF

find_out=$(OSORI_SEARCH_PATHS="$TEST_TMP/env-root" bash "$PROJECT_ROOT/scripts/telegram-commands.sh" find priority-proj work 2>&1)
assert_contains "find via search output" "$find_out" "Found via search"
assert_contains "find prefers root path over env path" "$find_out" "$TEST_TMP/work-root/priority-proj"
assert_not_contains "find does not pick env path first" "$find_out" "$TEST_TMP/env-root/priority-proj"
teardown_test

echo ""
echo "=== test_telegram_scan_root_arg ==="
setup_test
mkdir -p "$TEST_TMP/scan-repos/proj1"
git -C "$TEST_TMP/scan-repos/proj1" init -q 2>/dev/null
git -C "$TEST_TMP/scan-repos/proj1" config user.email "[REDACTED_SECRET]"
git -C "$TEST_TMP/scan-repos/proj1" config user.name "osori-test"
echo "scan" > "$TEST_TMP/scan-repos/proj1/README.md"
git -C "$TEST_TMP/scan-repos/proj1" add README.md >/dev/null 2>&1
git -C "$TEST_TMP/scan-repos/proj1" commit -m "init" >/dev/null 2>&1 || true

scan_out=$(bash "$PROJECT_ROOT/scripts/telegram-commands.sh" scan "$TEST_TMP/scan-repos" work 2>&1)
assert_contains "telegram scan prints root" "$scan_out" "root=work"
content=$(cat "$TEST_TMP/osori.json")
assert_contains "scanned project has root key" "$content" '"root": "work"'
teardown_test

echo ""
echo "=== test_injection_safe ==="
setup_test
mkdir -p "$TEST_TMP/evil-project"
git -C "$TEST_TMP/evil-project" init -q 2>/dev/null
EVIL_NAME='test"; rm -rf /; echo "'
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/evil-project" --name "$EVIL_NAME" 2>&1 || true
valid=$(python3 - << PYEOF
import json
try:
    json.load(open('$TEST_TMP/osori.json', encoding='utf-8'))
    print('valid')
except Exception:
    print('invalid')
PYEOF
)
assert_eq "registry valid after injection attempt" "valid" "$valid"
teardown_test

echo ""
echo "=== test_registry_env ==="
setup_test
CUSTOM_PATH="$TEST_TMP/custom/path/registry.json"
export OSORI_REGISTRY="$CUSTOM_PATH"
bash "$PROJECT_ROOT/scripts/add-project.sh" "$TEST_TMP/fake-project" --name "envtest" >/dev/null 2>&1
assert_file_exists "registry at custom env path" "$CUSTOM_PATH"
content=$(cat "$CUSTOM_PATH")
assert_contains "project in custom registry" "$content" '"envtest"'
teardown_test

echo ""
echo "=== test_no_hardcoded_paths ==="
skill_content=$(cat "$PROJECT_ROOT/SKILL.md")
assert_not_contains "no /Volumes/eyedisk in SKILL.md" "$skill_content" "/Volumes/eyedisk"
assert_not_contains "no ~/Developer in SKILL.md" "$skill_content" "~/Developer"
for script in "$PROJECT_ROOT/scripts/"*.sh; do
  sc=$(cat "$script")
  sname=$(basename "$script")
  assert_not_contains "no hardcoded relative registry in $sname" "$sc" '"$(dirname "$0")/../osori.json"'
done

echo ""
echo "=== test_platform_notes ==="
skill_content=$(cat "$PROJECT_ROOT/SKILL.md")
assert_contains "SKILL.md mentions mdfind is macOS only" "$skill_content" "macOS"
assert_contains "SKILL.md mentions python3 dependency" "$skill_content" "python3"
assert_contains "SKILL.md mentions entire integration" "$skill_content" "entire"

# Summary
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}"
if [[ $FAILED -gt 0 ]]; then
  echo "Failed tests:"
  for e in "${ERRORS[@]}"; do echo "  - $e"; done
  exit 1
fi
echo "All tests passed! ✓"

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)