#!/usr/bin/env python3
"""Build the `<skill>-claude` reviewer overlays for the dsh-aris-panel corpus.

为什么是"薄覆盖层"而不是变换后的副本：
上游自带的 generate_codex_claude_review_overrides.py 以 skills/skills-codex/ 镜像为源，
但该镜像并不等价于主技能（实测 auto-review-loop 缺 9/32 节、proof-checker 缺 13/47 节）。
以它为源会静默丢失功能。这里改为：每个 -claude 变体在运行时加载**最新主技能全文**并执行，
只替换审稿通道（Codex MCP → claude-review MCP），替换规则沿用上游生成器的约定
（去掉 model/reasoning_effort、start 后轮询 review_status、文件型审稿需显式授予只读工具）。
这样每次上游同步后，功能一致性是构造保证的，而不是靠重新生成。

漂移守卫：若主技能出现本表未覆盖的 mcp__* 审稿工具，构建以退出码 2 失败，
强制在发布前更新替换表，而不是让变体悄悄漏掉新通道。

Usage:
  python tools/build_claude_overlays.py            # write overlays into <plugin>/skills
  python tools/build_claude_overlays.py --out DIR  # write elsewhere (tests)
  python tools/build_claude_overlays.py --check    # exit 1 if on-disk overlays are stale
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

SKILLS = Path(__file__).resolve().parents[1] / "skills"

# 保留的 9 个 Claude 审稿变体（用户决定；其余 6 个已删除，走默认 Codex 通道）
OVERLAYS = {
    "auto-review-loop": "自动审稿循环",
    "research-review": "研究深度审稿",
    "kill-argument": "最强拒稿模拟",
    "proof-checker": "证明严格验证",
    "auto-paper-improvement-loop": "论文自动改进循环",
    "research-refine": "研究方案精炼",
    "paper-plan": "论文大纲规划",
    "paper-write": "论文写作",
    "paper-figure": "论文图表生成",
}

# 主技能里出现的审稿通道工具 → 处理方式
MAPPED = {
    "mcp__codex__codex": "mcp__claude-review__review_start",
    "mcp__codex__codex-reply": "mcp__claude-review__review_reply_start",
}
EXCLUDED = {  # 其它显式后端：Claude 通道下不使用
    "mcp__manual_review__review",
    "mcp__manual_review__review_reply",
}
CLAUDE_TOOLS = [
    "mcp__claude-review__review_start",
    "mcp__claude-review__review_reply_start",
    "mcp__claude-review__review_status",
]
TOKEN_RE = re.compile(r"mcp__[a-z0-9_-]+__[a-z0-9_-]+")
FM_RE = re.compile(r"^---\n(.*?)\n---\n", re.S)


def field(fm: str, name: str) -> str | None:
    m = re.search(rf"^{re.escape(name)}:\s*(.*)$", fm, re.M)
    return m.group(1).strip() if m else None


def unquote(v: str) -> str:
    if v.startswith('"') and v.endswith('"'):
        try:
            return json.loads(v)
        except ValueError:
            return v[1:-1]
    return v.strip("'")


def split_tools(v: str) -> list[str]:
    out, depth, cur = [], 0, ""
    for ch in v:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        if ch == "," and depth == 0:
            out.append(cur.strip())
            cur = ""
        else:
            cur += ch
    if cur.strip():
        out.append(cur.strip())
    return out


def render(base: str, zh: str, base_text: str) -> str:
    fm = FM_RE.match(base_text)
    if not fm:
        raise ValueError(f"{base}: base SKILL.md has no frontmatter")
    head = fm.group(1)
    desc = unquote(field(head, "description") or "")
    hint = field(head, "argument-hint")
    tools = [t for t in split_tools(field(head, "allowed-tools") or "") if not TOKEN_RE.fullmatch(t) or t not in MAPPED and t not in EXCLUDED]
    for t in ["Read", "Skill", *CLAUDE_TOOLS]:
        if t not in tools:
            tools.append(t)
    lines = ["---", f'name: "{base}-claude"', "description: " + json.dumps(f"{zh}(Claude Code) — {desc}", ensure_ascii=False)]
    if hint:
        lines.append(f"argument-hint: {hint}")
    lines += [f"allowed-tools: {', '.join(tools)}", "---", ""]
    variants = ", ".join(f"`/{k}` → `/{k}-claude`" for k in OVERLAYS)
    body = f"""## Reviewer model (workbench-managed)

- Resolve `CLAUDE_MODEL` once from `$ARGUMENTS`:
  1. `— reviewer-model: <model>` if present;
  2. else `— claude-model: <model>` if present;
  3. else default `claude-fable-5`.
- Every `mcp__claude-review__review_start` / `mcp__claude-review__review_reply_start` call in this skill MUST include `model: <CLAUDE_MODEL>` so the workbench's model picker is honored.
- The reviewer is Claude (Anthropic family): traces record `review_independence: cross-family` when the executor is not Anthropic. If the executor is itself Anthropic-family, record `review_independence: same-family` and `independence_verified: false` instead — the default Codex route remains the independent acquittal path.

# {base} — Claude reviewer route

This skill is a **thin overlay**: it runs the latest upstream ARIS procedure of `/{base}` in full and changes **only the reviewer transport**. Functional parity with the original skill is by construction — never by a stale copy.

## Step 0 — Load the base procedure (mandatory)

1. Load the complete instructions of the base skill `/{base}` — via the skill-loading tool, or by reading `../{base}/SKILL.md` relative to this file (fallback: `$ARIS_REPO/skills/{base}/SKILL.md`).
2. If the base cannot be loaded, STOP and report it. Never reconstruct the procedure from memory.
3. Execute every phase, constant, gate, file and output contract of the base exactly as written — do not shorten, paraphrase, or skip steps. Pass `$ARGUMENTS` through unchanged.

## Reviewer transport substitution (the only change)

| Base skill says | Do this instead |
|---|---|
| `mcp__codex__codex` with `model` / `config` / `prompt` | `mcp__claude-review__review_start` with the same `prompt`, `model: <CLAUDE_MODEL>`; system-level reviewer framing may go in `system` |
| `mcp__codex__codex-reply` with `threadId` / `prompt` | `mcp__claude-review__review_reply_start` with the same `threadId` and `prompt`, `model: <CLAUDE_MODEL>` |
| Codex-only settings (`model_reasoning_effort`, `sandbox`, `approval-policy`, …) | drop them; keep the base's effort tier as the *depth* the review prompt demands |
| A review prompt that tells the reviewer to read files / paths itself | add `tools: "Read,Grep,Glob"` — the bridge grants the reviewer no tools by default |

After this start call, immediately save the returned `jobId` and poll `mcp__claude-review__review_status` with a bounded `waitSeconds` until `done=true`. Treat the completed status payload's `response` as the reviewer output, and save the completed `threadId` for any follow-up round.

Never call `mcp__codex__codex` / `mcp__codex__codex-reply` in this run.

## Identity, traces and routing

- Wherever the base records reviewer identity (state files, JSONL traces, audit headers): `reviewer_backend: claude-review`, `reviewer_model: <CLAUDE_MODEL>`, `reviewer_family: anthropic`, independence per the rule above.
- Steps that auto-select or probe a reviewer backend are bypassed — the backend is fixed to `claude-review` for this run.
- If the base defines a `difficulty: nightmare` path (reviewer reads the repo directly via `codex exec`), there is no claude-review equivalent: STOP and suggest `difficulty: hard`, or the default Codex route.
- Other explicit backends (manual, oracle-pro, agy/gemini, copilot) are not used here; a user who wants one of them should call the base skill `/{base}` directly.
- Nested ARIS skill calls use the Claude variant where one exists ({variants}); any other nested skill stays on its default route and the final report states `reviewer: claude (partial)`.
- Never fabricate or paraphrase a reviewer verdict. If claude-review is unavailable, record the review as unavailable and handle the gate exactly as the base handles an unavailable reviewer — never self-review.
"""
    return "\n".join(lines) + body


def drift(base: str, base_text: str) -> list[str]:
    return sorted({t for t in TOKEN_RE.findall(base_text) if t not in MAPPED and t not in EXCLUDED and not t.startswith("mcp__claude-review__")})


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--base-root", type=Path, default=SKILLS)
    ap.add_argument("--out", type=Path, default=SKILLS)
    ap.add_argument("--check", action="store_true", help="exit 1 if on-disk overlays differ from a fresh build")
    a = ap.parse_args(argv)
    unknown, stale = {}, []
    for base, zh in OVERLAYS.items():
        text = (a.base_root / base / "SKILL.md").read_text(encoding="utf-8").replace("\r\n", "\n")
        if d := drift(base, text):
            unknown[base] = d
            continue
        out = render(base, zh, text)
        target = a.out / f"{base}-claude" / "SKILL.md"
        if a.check:
            if not target.exists() or target.read_text(encoding="utf-8") != out:
                stale.append(target.parent.name)
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(out, encoding="utf-8", newline="\n")
    if unknown:
        print("reviewer-tool drift (update MAPPED/EXCLUDED before shipping):", json.dumps(unknown), file=sys.stderr)
        return 2
    if stale:
        print("stale overlays:", ", ".join(stale), file=sys.stderr)
        return 1
    print(("checked " if a.check else "built ") + f"{len(OVERLAYS)} overlays -> {a.out}")
    return 0


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