Skill: buzuweidao/stuck (version 1.0.0)

--- name: stuck description: Generate a structured help-request document when stuck with AI coding. Scans specified conversation sessions, extracts errors and failed attempts, outputs a clean HELP_REQUEST.md. Triggers on "stuck", "卡住了", "AI修不好", "生成求助文档", "help request". metadata: openclaw: requires: bins: - python3 emoji: "🆘" homepage: "https://github.com/buzuweidao/skills" --- # stuck 从 AI 对话记录中提取错误信息，生成一份结构化的求助文档。 ## 触发条件 用户说 stuck、卡住了、AI 修不好、生成求助文档、help request。 ## 强制流程 这是一个引导式交互流程。每一步必须严格按顺序执行，不能跳过，不能合并，不能自动推进。每一步结束后必须停下来等用户响应，收到用户明确的回复后才能进入下一步。 ### 步骤 1：问问题 问用户： > 你卡在什么问题上？一句话描述。 **然后停下来。等用户回答。不做任何其他事情。** ### 步骤 2：确定对话范围 收到步骤 1 的回答后，运行以下命令列出最近的对话： ``` python3 ~/.claude/skills/stuck/scripts/parse_conversations.py list-all <当前工作目录的绝对路径> ``` 将结果加上编号展示给用户（如 [1] claude_code 2026-03-29 ..., [2] codex_cli 2026-03-28 ...），然后问： > 这个问题你在哪几个对话里处理的？请告诉我编号。 **然后停下来。等用户选择。不做任何其他事情。** ### 步骤 3：提取错误 收到步骤 2 的回答后，对用户指定的每个对话文件，运行以下命令提取信息： ``` python3 ~/.claude/skills/stuck/scripts/parse_conversations.py summary <文件绝对路径> ``` 注意：如果文件大于 10MB，提醒用户该文件较大，提取可能需要一些时间，并只提取最近 50 轮交互的内容。 从提取结果中获取： - 报错信息（原文） - AI 尝试了哪些方案 - 交互了多少轮 - 哪些方案失败了，失败的表现是什么 从提取结果中自动判断失败模式： - 同一个错反复出现 → 死循环 - 修好 A 坏了 B → 退化 - 每次方向完全不同 → 发散 - AI 说修好了但错误还在 → 沉默失败 提取完成后，把提取结果的摘要展示给用户，然后问： > 这些信息准确吗？有没有遗漏的关键错误或需要补充的上下文？ **然后停下来。等用户确认。不做任何其他事情。** ### 步骤 4：生成文档 收到步骤 3 的确认后，生成 `HELP_REQUEST.md` 到项目目录，包含： ``` # 求助文档 ## 问题 {用户在步骤1描述的一句话} ## 环境 - AI 工具: {从对话记录自动检测} - 技术栈: {从项目文件自动检测} - 持续时间: {从对话时间戳计算} - 交互轮数: {从对话记录统计} ## 错误信息 {从对话记录提取的关键报错，原文} ## AI 尝试过的方案 {按时间顺序列出，标注每个方案的结果} ## 失败模式 {工具自动判断的结果} ## 已排除的方向 {确认无效的方案，避免帮助者重复建议} ## 相关代码 {问题所在的文件和关键函数} ``` 保存前自动脱敏： - API key 格式的字符串 → [REDACTED] - 包含用户名的绝对路径 → 相对路径 - 邮箱地址 → [REDACTED] 保存后展示给用户，然后问： > 文档已生成。你看一下有没有需要修改的地方？ **然后停下来。等用户确认。** 用户确认后，流程结束。 ## 绝对规则 - **每一步之间必须停下来等用户回复。绝对不能跳步、合并步骤、或在用户没回复时自动推进。** - 只扫描用户指定的对话，不扫描全部 - 失败模式由工具自动判断，不问用户选择 - 错误信息保留原文，不总结不改写 - 文档面向的读者是对项目零了解的人或 AI

path: scripts/parse_conversations.py
content: #!/usr/bin/env python3
"""Parse AI coding tool conversation history files.

Supports:
  - Claude Code:  ~/.claude/projects/<path>/*.jsonl
  - Codex CLI:    ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
  - OpenClaw:     ~/.openclaw/agents/<agentId>/sessions/*.jsonl
  - Gemini CLI:   ~/.gemini/tmp/<hash>/chats/*.json
"""

import json
import sys
import os
import glob
import platform
import re
from datetime import datetime, timezone
from pathlib import Path


ERROR_KEYWORDS = [
    "error", "Error", "ERROR", "failed", "Failed", "FAILED",
    "exception", "Exception", "traceback", "Traceback",
    "TypeError", "SyntaxError", "ReferenceError", "ModuleNotFoundError",
    "ImportError", "ValueError", "KeyError", "AttributeError",
    "RuntimeError", "FileNotFoundError", "PermissionError",
    "ENOENT", "EACCES", "segfault", "panic", "SIGABRT",
]


def _has_error(text: str) -> bool:
    return any(kw in text for kw in ERROR_KEYWORDS)


def _file_info(filepath: str) -> dict:
    stat = os.stat(filepath)
    return {
        "path": filepath,
        "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
        "size_kb": round(stat.st_size / 1024, 1),
    }


def _parse_jsonl(filepath: str, max_lines: int = 0) -> list:
    """Parse JSONL file. If file > 10MB, only read last 5000 lines."""
    file_size = os.path.getsize(filepath)
    messages = []

    if file_size > 10 * 1024 * 1024:  # > 10MB
        # Read only the tail of large files
        import collections
        tail = collections.deque(maxlen=5000)
        with open(filepath, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line:
                    tail.append(line)
        for line in tail:
            try:
                messages.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    else:
        with open(filepath, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    messages.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
    return messages


def _extract_text(content) -> str:
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        return " ".join(
            p.get("text", "") for p in content
            if isinstance(p, dict) and p.get("type") == "text"
        )
    return ""


def _build_summary(tool, user_msgs, assistant_msgs, tool_uses, errors):
    return {
        "tool": tool,
        "total_user_msgs": len(user_msgs),
        "total_assistant_msgs": len(assistant_msgs),
        "total_tool_uses": len(tool_uses),
        "total_errors": len(errors),
        "user_messages": user_msgs[-20:],
        "assistant_messages": assistant_msgs[-10:],
        "tool_uses": tool_uses[-20:],
        "errors": errors,
    }


# --- Claude Code ---

def find_claude(project_dir: str) -> list:
    normalized = project_dir.replace("/", "-")
    if not normalized.startswith("-"):
        normalized = "-" + normalized
    claude_projects = os.path.expanduser("~/.claude/projects")
    conversations = []
    if os.path.isdir(claude_projects):
        for entry in os.listdir(claude_projects):
            entry_path = os.path.join(claude_projects, entry)
            if os.path.isdir(entry_path) and normalized in entry:
                for f in glob.glob(os.path.join(entry_path, "*.jsonl")):
                    info = _file_info(f)
                    info["tool"] = "claude_code"
                    info["project"] = entry
                    conversations.append(info)
    conversations.sort(key=lambda x: x["modified"], reverse=True)
    return conversations


def summarize_claude(filepath: str) -> dict:
    messages = _parse_jsonl(filepath)
    user_msgs, asst_msgs, tool_uses, errors = [], [], [], []
    for msg in messages:
        t = msg.get("type", "")
        ts = msg.get("timestamp", "")
        if t == "user":
            content = _extract_text(msg.get("message", {}).get("content", msg.get("content", "")))
            if content:
                user_msgs.append({"content": content[:500], "timestamp": ts})
        elif t == "assistant":
            content = _extract_text(msg.get("message", {}).get("content", ""))
            if content:
                asst_msgs.append({"content": content[:500], "timestamp": ts})
        elif t == "tool_use":
            tool_uses.append({
                "tool": msg.get("tool_name", "unknown"),
                "input_preview": str(msg.get("tool_input", ""))[:200],
                "timestamp": ts,
            })
        elif t == "tool_result":
            output = msg.get("tool_output", {})
            content = str(output.get("stderr", "") or output.get("stdout", output.get("output", "")) if isinstance(output, dict) else output)
            if _has_error(content):
                errors.append({"tool": msg.get("tool_name", "unknown"), "error": content[:500], "timestamp": ts})
    return _build_summary("claude_code", user_msgs, asst_msgs, tool_uses, errors)


# --- Codex CLI ---

def find_codex(project_dir: str = None) -> list:
    codex_sessions = os.path.expanduser("~/.codex/sessions")
    conversations = []
    if not os.path.isdir(codex_sessions):
        return conversations
    for f in glob.glob(os.path.join(codex_sessions, "**", "rollout-*.jsonl"), recursive=True):
        info = _file_info(f)
        info["tool"] = "codex_cli"
        info["project"] = os.path.dirname(f).replace(codex_sessions, "").strip("/")
        conversations.append(info)
    history = os.path.expanduser("~/.codex/history.jsonl")
    if os.path.isfile(history):
        info = _file_info(history)
        info["tool"] = "codex_cli"
        info["project"] = "global_history"
        conversations.append(info)
    conversations.sort(key=lambda x: x["modified"], reverse=True)
    return conversations


def summarize_codex(filepath: str) -> dict:
    messages = _parse_jsonl(filepath)
    user_msgs, asst_msgs, tool_uses, errors = [], [], [], []
    for msg in messages:
        role = msg.get("role", msg.get("type", ""))
        content = _extract_text(msg.get("content", ""))[:500]
        ts = msg.get("timestamp", msg.get("created_at", ""))
        if role in ("user", "human"):
            if content:
                user_msgs.append({"content": content, "timestamp": ts})
        elif role in ("assistant", "model"):
            if content:
                asst_msgs.append({"content": content, "timestamp": ts})
            for tc in (msg.get("tool_calls", msg.get("function_calls", [])) or []):
                tool_uses.append({
                    "tool": tc.get("function", {}).get("name", tc.get("name", "unknown")),
                    "input_preview": str(tc.get("function", {}).get("arguments", tc.get("input", "")))[:200],
                    "timestamp": ts,
                })
        elif role == "tool":
            if _has_error(content):
                errors.append({"tool": msg.get("name", "unknown"), "error": content[:500], "timestamp": ts})
    return _build_summary("codex_cli", user_msgs, asst_msgs, tool_uses, errors)


# --- OpenClaw ---

def find_openclaw(project_dir: str = None) -> list:
    openclaw_dir = os.path.expanduser("~/.openclaw/agents")
    conversations = []
    if not os.path.isdir(openclaw_dir):
        return conversations
    for agent_dir in os.listdir(openclaw_dir):
        sessions_dir = os.path.join(openclaw_dir, agent_dir, "sessions")
        if not os.path.isdir(sessions_dir):
            continue
        for f in glob.glob(os.path.join(sessions_dir, "*.jsonl")):
            info = _file_info(f)
            info["tool"] = "openclaw"
            info["project"] = agent_dir
            conversations.append(info)
    conversations.sort(key=lambda x: x["modified"], reverse=True)
    return conversations[:20]


def summarize_openclaw(filepath: str) -> dict:
    messages = _parse_jsonl(filepath)
    user_msgs, asst_msgs, tool_uses, errors = [], [], [], []
    for msg in messages:
        t = msg.get("type", "")
        ts = msg.get("timestamp", "")
        if t == "session":
            continue
        if t == "message":
            inner = msg.get("message", {})
            role = inner.get("role", "")
            content = _extract_text(inner.get("content", ""))[:500]
            if role == "user" and content:
                user_msgs.append({"content": content, "timestamp": ts})
            elif role == "assistant" and content:
                asst_msgs.append({"content": content, "timestamp": ts})
                if _has_error(content):
                    errors.append({"tool": "openclaw", "error": content[:500], "timestamp": ts})
            elif role == "toolResult" and content:
                if _has_error(content):
                    errors.append({"tool": "openclaw", "error": content[:500], "timestamp": ts})
            # Extract tool calls from assistant content blocks
            raw_content = inner.get("content", [])
            if isinstance(raw_content, list):
                for block in raw_content:
                    if isinstance(block, dict) and block.get("type") == "tool_use":
                        tool_uses.append({
                            "tool": block.get("name", "unknown"),
                            "input_preview": str(block.get("input", ""))[:200],
                            "timestamp": ts,
                        })
        else:
            # Fallback: try role-based parsing
            role = msg.get("role", "")
            content = _extract_text(msg.get("content", ""))[:500]
            if role == "user" and content:
                user_msgs.append({"content": content, "timestamp": ts})
            elif role == "assistant" and content:
                asst_msgs.append({"content": content, "timestamp": ts})
    return _build_summary("openclaw", user_msgs, asst_msgs, tool_uses, errors)


# --- Gemini CLI ---

def find_gemini(project_dir: str = None) -> list:
    gemini_tmp = os.path.expanduser("~/.gemini/tmp")
    conversations = []
    if not os.path.isdir(gemini_tmp):
        return conversations
    for hash_dir in os.listdir(gemini_tmp):
        chats_dir = os.path.join(gemini_tmp, hash_dir, "chats")
        if not os.path.isdir(chats_dir):
            continue
        for f in glob.glob(os.path.join(chats_dir, "*.json")):
            info = _file_info(f)
            info["tool"] = "gemini_cli"
            info["project"] = hash_dir[:12]
            conversations.append(info)
    conversations.sort(key=lambda x: x["modified"], reverse=True)
    return conversations[:20]


def summarize_gemini(filepath: str) -> dict:
    user_msgs, asst_msgs, tool_uses, errors = [], [], [], []
    with open(filepath, "r", encoding="utf-8") as f:
        try:
            data = json.load(f)
        except json.JSONDecodeError:
            return _build_summary("gemini_cli", [], [], [], [])

    messages = data if isinstance(data, list) else data.get("messages", data.get("history", []))
    if not isinstance(messages, list):
        return _build_summary("gemini_cli", [], [], [], [])

    for msg in messages:
        if not isinstance(msg, dict):
            continue
        role = msg.get("role", msg.get("author", ""))
        content = _extract_text(msg.get("content", msg.get("parts", msg.get("text", ""))))[:500]
        ts = msg.get("timestamp", msg.get("createTime", ""))

        if role in ("user", "human"):
            if content:
                user_msgs.append({"content": content, "timestamp": ts})
        elif role in ("model", "assistant"):
            if content:
                asst_msgs.append({"content": content, "timestamp": ts})
            if _has_error(content):
                errors.append({"tool": "gemini_cli", "error": content[:500], "timestamp": ts})
            # Tool calls
            for part in (msg.get("parts", []) if isinstance(msg.get("parts"), list) else []):
                if isinstance(part, dict) and part.get("functionCall"):
                    fc = part["functionCall"]
                    tool_uses.append({
                        "tool": fc.get("name", "unknown"),
                        "input_preview": str(fc.get("args", ""))[:200],
                        "timestamp": ts,
                    })

    return _build_summary("gemini_cli", user_msgs, asst_msgs, tool_uses, errors)


# --- Unified Interface ---

TOOL_FINDERS = {
    "claude_code": find_claude,
    "codex_cli": find_codex,
    "openclaw": find_openclaw,
    "gemini_cli": find_gemini,
}

TOOL_SUMMARIZERS = {
    "claude_code": summarize_claude,
    "codex_cli": summarize_codex,
    "openclaw": summarize_openclaw,
    "gemini_cli": summarize_gemini,
}


def detect_tools(project_dir: str) -> dict:
    found = {}
    for tool_name, finder in TOOL_FINDERS.items():
        try:
            convos = finder(project_dir)
            if convos:
                found[tool_name] = {
                    "count": len(convos),
                    "most_recent": convos[0]["modified"],
                    "conversations": convos[:5],
                }
        except Exception:
            continue
    return found


def auto_summary(filepath: str, tool: str = None) -> dict:
    if tool and tool in TOOL_SUMMARIZERS:
        return TOOL_SUMMARIZERS[tool](filepath)
    # Auto detect
    if ".codex" in filepath:
        return summarize_codex(filepath)
    elif ".openclaw" in filepath:
        return summarize_openclaw(filepath)
    elif ".gemini" in filepath:
        return summarize_gemini(filepath)
    else:
        return summarize_claude(filepath)


def main():
    if len(sys.argv) < 2:
        print("Usage: parse_conversations.py <command> [args]")
        print()
        print("Commands:")
        print("  detect <project_dir>      Detect all AI tools with conversation history")
        print("  list <tool> [project_dir]  List conversations for a specific tool")
        print("  list-all [project_dir]     List conversations from all detected tools")
        print("  summary <file> [tool]      Summarize a conversation file")
        print()
        print("Supported tools: claude_code, codex_cli, openclaw, gemini_cli")
        sys.exit(1)

    command = sys.argv[1]

    if command == "detect":
        project_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
        result = detect_tools(project_dir)
        print(json.dumps(result, indent=2, ensure_ascii=False))

    elif command == "list":
        if len(sys.argv) < 3:
            print("Usage: parse_conversations.py list <tool> [project_dir]")
            sys.exit(1)
        tool = sys.argv[2]
        project_dir = sys.argv[3] if len(sys.argv) > 3 else os.getcwd()
        if tool not in TOOL_FINDERS:
            print(f"Unknown tool: {tool}. Supported: {', '.join(TOOL_FINDERS.keys())}")
            sys.exit(1)
        convos = TOOL_FINDERS[tool](project_dir)
        print(json.dumps(convos, indent=2, ensure_ascii=False))

    elif command == "list-all":
        project_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
        all_convos = []
        for tool_name, finder in TOOL_FINDERS.items():
            try:
                all_convos.extend(finder(project_dir))
            except Exception:
                continue
        all_convos.sort(key=lambda x: x["modified"], reverse=True)
        # 只显示最近 30 个，带编号
        for i, c in enumerate(all_convos[:30]):
            size_str = f"{c['size_kb']:.0f}KB" if c['size_kb'] < 1024 else f"{c['size_kb']/1024:.1f}MB"
            mod = c['modified'][:16].replace('T', ' ')
            print(f"[{i+1:2d}] {c['tool']:12s} {mod} {size_str:>8s}  {os.path.basename(c['path'])}")
        # 同时输出 JSON 到 stderr 供程序读取
        print(json.dumps(all_convos[:30], ensure_ascii=False), file=sys.stderr)

    elif command == "summary":
        if len(sys.argv) < 3:
            print("Usage: parse_conversations.py summary <file> [tool]")
            sys.exit(1)
        filepath = sys.argv[2]
        tool = sys.argv[3] if len(sys.argv) > 3 else None
        result = auto_summary(filepath, tool)
        print(json.dumps(result, indent=2, ensure_ascii=False))

    else:
        print(f"Unknown command: {command}")
        sys.exit(1)


if __name__ == "__main__":
    main()


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