From e780ed6213b9245a6643032549d39c30d32e7918 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Thu, 3 Sep 2026 11:27:57 +0800 Subject: [PATCH] Update Caveman Codex plugin integration --- README.md | 2 + config/external-sources.json | 69 +- .../plugins/caveman/.codex-plugin/plugin.json | 11 +- .../caveman/skills/caveman-commit/SKILL.md | 61 ++ .../caveman/skills/caveman-compress/SKILL.md | 107 +++ .../caveman-compress/scripts/__init__.py | 9 + .../caveman-compress/scripts/__main__.py | 3 + .../caveman-compress/scripts/benchmark.py | 80 ++ .../skills/caveman-compress/scripts/cli.py | 85 +++ .../caveman-compress/scripts/compress.py | 717 ++++++++++++++++++ .../skills/caveman-compress/scripts/detect.py | 140 ++++ .../caveman-compress/scripts/validate.py | 408 ++++++++++ .../caveman/skills/caveman-review/SKILL.md | 51 ++ .../caveman/skills/investigate-first/SKILL.md | 16 + .../investigate-first/agents/openai.yaml | 4 + .../caveman/skills/lean-build/SKILL.md | 18 + .../skills/lean-build/agents/openai.yaml | 4 + .../plugins/caveman/skills/migration/SKILL.md | 17 + .../skills/migration/agents/openai.yaml | 4 + .../caveman/skills/safe-refactor/SKILL.md | 16 + .../skills/safe-refactor/agents/openai.yaml | 4 + .../caveman/skills/surgical-patch/SKILL.md | 16 + .../skills/surgical-patch/agents/openai.yaml | 4 + .../caveman/skills/verify-and-stop/SKILL.md | 16 + .../skills/verify-and-stop/agents/openai.yaml | 4 + scripts/sync_external_plugins.ps1 | 34 + 26 files changed, 1887 insertions(+), 13 deletions(-) create mode 100644 plugins/codex/plugins/caveman/skills/caveman-commit/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__init__.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__main__.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/benchmark.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py create mode 100644 plugins/codex/plugins/caveman/skills/caveman-review/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/investigate-first/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/investigate-first/agents/openai.yaml create mode 100644 plugins/codex/plugins/caveman/skills/lean-build/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/lean-build/agents/openai.yaml create mode 100644 plugins/codex/plugins/caveman/skills/migration/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/migration/agents/openai.yaml create mode 100644 plugins/codex/plugins/caveman/skills/safe-refactor/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/safe-refactor/agents/openai.yaml create mode 100644 plugins/codex/plugins/caveman/skills/surgical-patch/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/surgical-patch/agents/openai.yaml create mode 100644 plugins/codex/plugins/caveman/skills/verify-and-stop/SKILL.md create mode 100644 plugins/codex/plugins/caveman/skills/verify-and-stop/agents/openai.yaml diff --git a/README.md b/README.md index f3e18d9f..9176a127 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ 部分插件依赖特定运行环境。`$updating-gitea-repositories` 面向 Gitea HTTPS、GCM 和 PAT 工作流;`$etunel-role-collaboration` 仅在 Etunel 提供角色、成员、Hook 和任务上下文时使用。安装前应查看插件详情并确认运行前置条件。 +`caveman` 插件中的大多数工作流可直接使用。可选的 `$caveman-compress` 需要 Python 3.10,以及 `ANTHROPIC_API_KEY` 或已登录的 Claude CLI;仅在用户明确指定文件后执行,并会把该文件内容发送给 Anthropic。该插件不包含 Claude Code 专用统计 hooks、statusline、Caveman CLI、代理或 MCP shrink。 + 如果插件刚安装到当前 Codex 会话中,启动新的 Codex thread 或新的 Codex 调用后再依赖新增 skills/hooks/MCP/app 配置。 卸载插件: diff --git a/config/external-sources.json b/config/external-sources.json index 001c8c4d..445cd8cd 100644 --- a/config/external-sources.json +++ b/config/external-sources.json @@ -166,7 +166,16 @@ "category": "Developer Tools", "sourcePath": "plugins/caveman", "sparseCheckout": [ - "plugins/caveman" + "package.json", + "plugins/caveman", + "skills/caveman-commit/SKILL.md", + "skills/caveman-review/SKILL.md", + "skills/investigate-first", + "skills/lean-build", + "skills/migration", + "skills/safe-refactor", + "skills/surgical-patch", + "skills/verify-and-stop" ], "include": [ ".codex-plugin", @@ -175,15 +184,49 @@ "agents" ], "excludePaths": [ - "skills/caveman-stats", - "skills/caveman-compress" + "skills/caveman-stats" ], + "materializePaths": [ + { + "source": "skills/caveman-commit/SKILL.md", + "destination": "skills/caveman-commit/SKILL.md" + }, + { + "source": "skills/caveman-review/SKILL.md", + "destination": "skills/caveman-review/SKILL.md" + }, + { + "source": "skills/investigate-first", + "destination": "skills/investigate-first" + }, + { + "source": "skills/lean-build", + "destination": "skills/lean-build" + }, + { + "source": "skills/migration", + "destination": "skills/migration" + }, + { + "source": "skills/safe-refactor", + "destination": "skills/safe-refactor" + }, + { + "source": "skills/surgical-patch", + "destination": "skills/surgical-patch" + }, + { + "source": "skills/verify-and-stop", + "destination": "skills/verify-and-stop" + } + ], + "manifestVersionPath": "package.json", "manifestOverrides": { - "description": "Caveman 是面向 Codex 的极简输出风格和压缩式协作 skill 集合。", + "description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。", "interface": { "displayName": "Caveman", - "shortDescription": "让 Codex 输出更短,同时保留技术准确性。", - "longDescription": "Caveman 提供极简输出风格和压缩式子 Agent 协作指引。此公共市场版本只发布 Codex plugin 内可直接使用的 skills;不包含上游一键安装器、Claude Code hooks/statusline、MCP shrink、根目录 slash commands,以及依赖 Anthropic API 或 Claude CLI 的压缩脚本 skill。", + "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。", + "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。另含可选的 caveman-compress;它需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", "developerName": "Julius Brussee", "category": "Developer Tools", "capabilities": [ @@ -192,7 +235,8 @@ ], "defaultPrompt": [ "使用 Caveman 模式回答,压缩表达但保留技术准确性。", - "使用 Cavecrew 帮我判断这个任务是否适合分派给压缩输出的子 Agent。" + "使用 investigate-first 先调查这个故障的可信根因。", + "使用 surgical-patch 对这个问题做最小且经过验证的修复。" ], "websiteURL": "https://github.com/JuliusBrussee/caveman", "privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md", @@ -202,7 +246,16 @@ }, "skillDescriptions": { "caveman": "让 Codex 使用更短、更直接的表达,同时保留代码、命令、错误和技术细节。", - "cavecrew": "判断何时使用压缩输出的调查、编辑或评审子 Agent,以降低长会话上下文消耗。" + "cavecrew": "判断何时使用压缩输出的调查、编辑或评审子 Agent,以降低长会话上下文消耗。", + "caveman-compress": "压缩 Markdown 等自然语言文件并保留可读备份;需要 Python 3.10 和 Anthropic API Key 或 Claude CLI。", + "caveman-commit": "生成精简、准确且符合 Conventional Commits 的提交信息,不执行 Git 操作。", + "caveman-review": "以文件和行号为中心输出简短、可执行的代码评审发现。", + "investigate-first": "在修改代码前收集证据、排列假设并确定可信根因。", + "lean-build": "以最小完整范围实现功能,并以明确验收条件约束扩展。", + "migration": "规划并实施可回滚、兼容且保护现有数据的迁移。", + "safe-refactor": "在保持外部行为不变的前提下进行结构调整并验证结果。", + "surgical-patch": "在职责归属最窄的层修复缺陷,并提供针对性回归验证。", + "verify-and-stop": "运行满足验收条件所需的最小验证集,避免额外扩大范围。" } }, { diff --git a/plugins/codex/plugins/caveman/.codex-plugin/plugin.json b/plugins/codex/plugins/caveman/.codex-plugin/plugin.json index 3be865b7..85d2a566 100644 --- a/plugins/codex/plugins/caveman/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/caveman/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "caveman", - "version": "0.1.0", - "description": "Caveman 是面向 Codex 的极简输出风格和压缩式协作 skill 集合。", + "version": "2.5.0", + "description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。", "author": { "name": "Julius Brussee", "url": "https://github.com/JuliusBrussee" @@ -18,8 +18,8 @@ "skills": "./skills/", "interface": { "displayName": "Caveman", - "shortDescription": "让 Codex 输出更短,同时保留技术准确性。", - "longDescription": "Caveman 提供极简输出风格和压缩式子 Agent 协作指引。此公共市场版本只发布 Codex plugin 内可直接使用的 skills;不包含上游一键安装器、Claude Code hooks/statusline、MCP shrink、根目录 slash commands,以及依赖 Anthropic API 或 Claude CLI 的压缩脚本 skill。", + "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。", + "longDescription": "Caveman 提供精简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。另含可选的 caveman-compress;它需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooks、statusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。", "developerName": "Julius Brussee", "category": "Developer Tools", "capabilities": [ @@ -31,7 +31,8 @@ "termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE", "defaultPrompt": [ "使用 Caveman 模式回答,压缩表达但保留技术准确性。", - "使用 Cavecrew 帮我判断这个任务是否适合分派给压缩输出的子 Agent。" + "使用 investigate-first 先调查这个故障的可信根因。", + "使用 surgical-patch 对这个问题做最小且经过验证的修复。" ], "composerIcon": "./assets/caveman-small.svg", "logo": "./assets/caveman.svg", diff --git a/plugins/codex/plugins/caveman/skills/caveman-commit/SKILL.md b/plugins/codex/plugins/caveman/skills/caveman-commit/SKILL.md new file mode 100644 index 00000000..0fe6924f --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-commit/SKILL.md @@ -0,0 +1,61 @@ +--- +name: caveman-commit +description: "生成精简、准确且符合 Conventional Commits 的提交信息,不执行 Git 操作。" +--- + +Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what. + +## Rules + +**Subject line:** +- `(): ` — `` optional +- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert` +- Imperative mood: "add", "fix", "remove" — not "added", "adds", "adding" +- ≤50 chars when possible, hard cap 72 +- No trailing period +- Match project convention for capitalization after the colon + +**Body (only if needed):** +- Skip entirely when subject is self-explanatory +- Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues +- Wrap at 72 chars +- Bullets `-` not `*` +- Reference issues/PRs at end: `Closes #42`, `Refs #17` + +**What NEVER goes in:** +- "This commit does X", "I", "we", "now", "currently" — the diff says what +- "As requested by..." — use Co-authored-by trailer +- "Generated with Claude Code" or any AI attribution — unless the user's own rule requires an `Assisted-by`/AI-attribution trailer, then add it as a trailer +- Emoji (unless project convention requires) +- Restating the file name when scope already says it + +## Examples + +Diff: new endpoint for user profile with body explaining the why +- ❌ "feat: add a new endpoint to get user profile information from the database" +- ✅ + ``` + feat(api): add GET /users/:id/profile + + Mobile client needs profile data without the full user payload + to reduce LTE bandwidth on cold-launch screens. + + Closes #128 + ``` + +Diff: breaking API change +- ✅ + ``` + feat(api)!: rename /v1/orders to /v1/checkout + + BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout + before 2026-06-01. Old route returns 410 after that date. + ``` + +## Auto-Clarity + +Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never compress these into subject-only — future debuggers need the context. + +## Boundaries + +Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style. diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/SKILL.md b/plugins/codex/plugins/caveman/skills/caveman-compress/SKILL.md new file mode 100644 index 00000000..71f63a79 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/SKILL.md @@ -0,0 +1,107 @@ +--- +name: caveman-compress +description: "压缩 Markdown 等自然语言文件并保留可读备份;需要 Python 3.10 和 Anthropic API Key 或 Claude CLI。" +--- + +# Caveman Compress + +## Purpose + +Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups//`, or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows) so skill auto-loaders don't re-ingest it as a live file. + +## Trigger + +`/caveman-compress ` or when user asks to compress a memory file. + +## Process + +1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. + +2. From the directory containing this SKILL.md, run: + +python3 -m scripts + +3. The CLI will: +- detect file type (no tokens) +- call Claude to compress +- validate output (no tokens) +- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression) +- retry up to 2 times +- if still failing after 2 retries: report error to user, leave original file untouched + +4. Return result to user + +## Compression Rules + +### Remove +- Articles: a, an, the +- Filler: just, really, basically, actually, simply, essentially, generally +- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend" +- Hedging: "it might be worth", "you could consider", "it would be good to" +- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because" +- Connective fluff: "however", "furthermore", "additionally", "in addition" + +### Preserve EXACTLY (never modify) +- Code blocks (fenced ``` and indented) +- Inline code (`backtick content`) +- URLs and links (full URLs, markdown links) +- File paths (`/src/components/...`, `./config.yaml`) +- Commands (`npm install`, `git commit`, `docker build`) +- Technical terms (library names, API names, protocols, algorithms) +- Proper nouns (project names, people, companies) +- Dates, version numbers, numeric values +- Environment variables (`$HOME`, `NODE_ENV`) + +### Preserve Structure +- All markdown headings (keep exact heading text, compress body below) +- Bullet point hierarchy (keep nesting level) +- Numbered lists (keep numbering) +- Tables (compress cell text, keep structure) +- Frontmatter/YAML headers in markdown files + +### Compress +- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize" +- Fragments OK: "Run tests before commit" not "You should always run tests before committing" +- Drop "you should", "make sure to", "remember to" — just state the action +- Merge redundant bullets that say the same thing differently +- Keep one example where multiple examples show the same pattern + +CRITICAL RULE: +Anything inside ``` ... ``` must be copied EXACTLY. +Do not: +- remove comments +- remove spacing +- reorder lines +- shorten commands +- simplify anything + +Inline code (`...`) must be preserved EXACTLY. +Do not modify anything inside backticks. + +If file contains code blocks: +- Treat code blocks as read-only regions +- Only compress text outside them +- Do not merge sections around code + +## Pattern + +Original: +> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production. + +Compressed: +> Run tests before push to main. Catch bugs early, prevent broken prod deploys. + +Original: +> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens. + +Compressed: +> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens. + +## Boundaries + +- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless) +- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh +- If file has mixed content (prose + code), compress ONLY the prose sections +- If unsure whether something is code or prose, leave it unchanged +- Original file is backed up as FILE.original.md before overwriting — in the out-of-tree backup data dir (see Purpose), not beside the source file +- Never compress FILE.original.md (skip it) diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__init__.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__init__.py new file mode 100644 index 00000000..16b8c53c --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__init__.py @@ -0,0 +1,9 @@ +"""Caveman compress scripts. + +This package provides tools to compress natural language markdown files +into caveman format to save input tokens. +""" + +__all__ = ["cli", "compress", "detect", "validate"] + +__version__ = "1.0.0" diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__main__.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/benchmark.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/benchmark.py new file mode 100644 index 00000000..97d081b5 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/benchmark.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +# Support both direct execution and module import +try: + from .validate import validate +except ImportError: + sys.path.insert(0, str(Path(__file__).parent)) + from validate import validate + +try: + import tiktoken + _enc = tiktoken.get_encoding("o200k_base") +except ImportError: + _enc = None + + +def count_tokens(text): + if _enc is None: + return len(text.split()) # fallback: word count + return len(_enc.encode(text)) + + +def benchmark_pair(orig_path: Path, comp_path: Path): + orig_text = orig_path.read_text(encoding="utf-8", errors="ignore") + comp_text = comp_path.read_text(encoding="utf-8", errors="ignore") + + orig_tokens = count_tokens(orig_text) + comp_tokens = count_tokens(comp_text) + saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0 + result = validate(orig_path, comp_path) + + return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid) + + +def print_table(rows): + print("\n| File | Original | Compressed | Saved % | Valid |") + print("|------|----------|------------|---------|-------|") + for r in rows: + print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |") + + +def main(): + # Direct file pair: python3 benchmark.py original.md compressed.md + if len(sys.argv) == 3: + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + if not orig.exists(): + print(f"❌ Not found: {orig}") + sys.exit(1) + if not comp.exists(): + print(f"❌ Not found: {comp}") + sys.exit(1) + print_table([benchmark_pair(orig, comp)]) + return + + # Glob mode: repo_root/tests/caveman-compress/ + # __file__ lives at /skills/caveman-compress/scripts/benchmark.py + # Walk up four dirs: scripts → caveman-compress → skills → repo_root. + tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress" + if not tests_dir.exists(): + print(f"❌ Tests dir not found: {tests_dir}") + sys.exit(1) + + rows = [] + for orig in sorted(tests_dir.glob("*.original.md")): + comp = orig.with_name(orig.stem.removesuffix(".original") + ".md") + if comp.exists(): + rows.append(benchmark_pair(orig, comp)) + + if not rows: + print("No compressed file pairs found.") + return + + print_table(rows) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py new file mode 100644 index 00000000..75ea8a66 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Caveman Compress CLI + +Usage: + caveman +""" + +import sys + +# Force UTF-8 on stdout/stderr before any code can print. Windows consoles +# default to cp1252 and crash on the ❌ glyphs in error/validation branches, +# masking the real error and leaving the user with a half-compressed file. +for _stream in (sys.stdout, sys.stderr): + reconfigure = getattr(_stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from pathlib import Path + +from .compress import backup_dir_for, compress_file +from .detect import detect_file_type, should_compress + + +def print_usage(): + print("Usage: caveman ") + + +def main(): + if len(sys.argv) != 2: + print_usage() + sys.exit(1) + + filepath = Path(sys.argv[1]) + + # Check file exists + if not filepath.exists(): + print(f"❌ File not found: {filepath}") + sys.exit(1) + + if not filepath.is_file(): + print(f"❌ Not a file: {filepath}") + sys.exit(1) + + filepath = filepath.resolve() + + # Detect file type + file_type = detect_file_type(filepath) + + print(f"Detected: {file_type}") + + # Check if compressible + if not should_compress(filepath): + print("Skipping: file is not natural language (code/config)") + sys.exit(0) + + print("Starting caveman compression...\n") + + try: + success = compress_file(filepath) + + if success: + print("\nCompression completed successfully") + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + print(f"Compressed: {filepath}") + print(f"Original: {backup_path}") + sys.exit(0) + else: + print("\n❌ Compression failed after retries") + sys.exit(2) + + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(130) + + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py new file mode 100644 index 00000000..b5ba60a8 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py @@ -0,0 +1,717 @@ +#!/usr/bin/env python3 +""" +Caveman Memory Compression Orchestrator + +Usage: + python scripts/compress.py +""" + +import contextlib +import errno +import hashlib +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import List, Tuple + +_IS_WINDOWS = os.name == "nt" or sys.platform == "win32" +_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) # unix-only; refuses to open through a pre-placed symlink at the lock path + +if _IS_WINDOWS: + import msvcrt +else: + import fcntl + +# Windows consoles default to cp1252, which cannot encode the emoji glyphs in +# our status lines; replace unencodable characters instead of crashing. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(errors="replace") + except Exception: + pass + +# A fence marker at the start of a line, at CommonMark's 0-3 space indent. +FENCE_LINE_REGEX = re.compile(r"^\s{0,3}(`{3,}|~{3,})") + +# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line. +# Captures the entire block (including delimiters and trailing newline) and the body after. +FRONTMATTER_REGEX = re.compile( + r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL +) + + +def split_frontmatter(text: str): + """Split YAML frontmatter from body. Returns (frontmatter, body). + + Memory files (and many other markdown docs) start with a YAML frontmatter + block delimited by `---` lines. The compression LLM has a habit of stripping + or rewriting these despite preserve-structure rules in the prompt — so we + surgically remove the frontmatter before compression and prepend it back + verbatim to the output. Files without frontmatter pass through unchanged. + """ + m = FRONTMATTER_REGEX.match(text) + if m: + return m.group(1), m.group(2) + return "", text + +# Filenames and paths that almost certainly hold secrets or PII. Compressing +# them ships raw bytes to the Anthropic API — a third-party data boundary that +# developers on sensitive codebases cannot cross. detect.py already skips .env +# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would +# slip through the natural-language filter. This is a hard refuse before read. +SENSITIVE_BASENAME_REGEX = re.compile( + r"(?ix)^(" + r"\.env(\..+)?" + r"|\.netrc" + r"|credentials(\..+)?" + r"|secrets?(\..+)?" + r"|passwords?(\..+)?" + r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?" + r"|authorized_keys" + r"|known_hosts" + r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)" + r")$" +) + +SENSITIVE_PATH_COMPONENTS = frozenset({ + ".ssh", ".aws", ".gnupg", ".kube", ".docker", + "credential", "credentials", "secret", "secrets", +}) + +SENSITIVE_NAME_TOKENS = ( + "secret", "credential", "password", "passwd", + "apikey", "accesskey", "token", "privatekey", +) + + +def _state_base_dir(kind: str) -> Path: + """Shared platform-aware base dir for caveman-compress state (backups, locks) — Windows uses %LOCALAPPDATA%, else $XDG_DATA_HOME or ~/.local/share.""" + if _IS_WINDOWS: + local_appdata = os.environ.get("LOCALAPPDATA") + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + else: + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + return base / "caveman-compress" / kind + + +def backup_dir_for(filepath: Path) -> Path: + """Out-of-tree backup dir for filepath, keyed by its parent dir name — kept outside the source tree so skill auto-loaders don't re-ingest `.original.md` backups as live files.""" + return _state_base_dir("backups") / filepath.parent.name + + +LOCK_WAIT_SECONDS = 900 # must outlast a legitimate holder's worst-case run (up to MAX_RETRIES+1 Claude calls against the 500KB size cap) or a healthy wait misreads as a stuck lock +LOCK_POLL_INTERVAL = 1.0 + + +class LockTimeoutError(TimeoutError): + """Raised when another process holds the compress lock past LOCK_WAIT_SECONDS.""" + + +def lock_path_for(filepath: Path) -> Path: + """Cross-session lock path keyed on the same (parent-dir-name, stem) identity backup_dir_for uses for its own collision guard, derived from backup_dir_for itself so the two can't drift apart — two source files that would write the same backup path must also serialize on the same lock. Hashed into a fixed-length digest rather than embedded as plaintext so the key stays filesystem-safe regardless of the source path's length or characters.""" + resolved = filepath.resolve() + backup_path = backup_dir_for(resolved) / (resolved.stem + ".original.md") + digest = hashlib.sha256(str(backup_path).encode("utf-8")).hexdigest()[:16] + return _state_base_dir("locks") / f"{digest}.lock" + + +def _try_lock_nonblocking(fd: int) -> None: + """Attempt the OS-native exclusive lock on fd; raises BlockingIOError if another process already holds it.""" + if _IS_WINDOWS: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as e: + if e.errno != errno.EACCES: # EACCES is LK_NBLCK's documented contention errno; anything else is a real failure (bad fd, permissions, AV lock) and must not be mistaken for another session holding the file + raise + raise BlockingIOError(str(e)) from e + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock(fd: int) -> None: + """Release the OS-native lock on fd; swallows errors since callers use this in a finally block.""" + try: + if _IS_WINDOWS: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + + +@contextlib.contextmanager +def file_lock(filepath: Path): + """Cross-session exclusive lock on filepath's resolved path, backed by the OS's own file lock (fcntl.flock on POSIX, msvcrt.locking on Windows) — a crashed or killed holder releases it automatically, so unlike a hand-rolled marker file there's no staleness bookkeeping to get wrong.""" + lock_path = lock_path_for(filepath) + lock_dir = lock_path.parent + if lock_dir.is_symlink(): # best-effort: catches a pre-staged symlink at this exact component; mkdir(exist_ok=True) would otherwise follow it, and _O_NOFOLLOW below only guards the final path component + raise OSError(f"Refusing to use lock directory through a symlink: {lock_dir}") + lock_dir.mkdir(parents=True, exist_ok=True) + if not _IS_WINDOWS: # best-effort: keys are unsalted hashes of a guessable path, keep the directory listing to this user only; some CIFS/FAT/FUSE mounts reject chmod outright, so a failure here is not fatal + with contextlib.suppress(OSError): + os.chmod(lock_dir, 0o700) + if lock_path.is_symlink(): # best-effort on Windows too, where O_NOFOLLOW (POSIX-only, below) can't guard this component + raise OSError(f"Refusing to open lock file through a symlink: {lock_path}") + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | _O_NOFOLLOW, 0o600) + try: + if os.fstat(fd).st_size == 0: + os.write(fd, b"\0") # msvcrt.locking needs at least one byte in the file to lock + os.lseek(fd, 0, 0) + deadline = time.monotonic() + LOCK_WAIT_SECONDS + printed_waiting = False + while True: + try: + _try_lock_nonblocking(fd) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise LockTimeoutError( + f"Another caveman-compress run appears to be compressing {filepath} " + f"(lock: {lock_path}). Giving up after {LOCK_WAIT_SECONDS}s — retry once " + "it finishes." + ) from None + if not printed_waiting: # 900s of silence reads as a hang; tell the user once why nothing's happening yet. flush explicitly since stdout is a pipe when this script runs non-interactively + print(f"Waiting for another caveman-compress run to finish with {filepath}...", flush=True) + printed_waiting = True + time.sleep(LOCK_POLL_INTERVAL) + except OSError as e: + if e.errno in (errno.EOPNOTSUPP, errno.ENOSYS): # filesystem doesn't implement flock/byte-range locking at all (some NFS/SMB/FUSE mounts) — degrade to no coordination rather than fail a run that worked before this lock existed. ENOLCK deliberately excluded: it can mean transient kernel lock-record exhaustion, so it isn't a reliable "unsupported" signal — treating it as one could let a genuinely contended lock proceed unlocked + print(f"⚠️ {lock_dir}'s filesystem doesn't support file locking — proceeding without cross-session coordination.", flush=True) + break + raise + try: + yield + finally: + _unlock(fd) + finally: + os.close(fd) + + +def is_sensitive_path(filepath: Path) -> bool: + """Heuristic denylist for files that must never be shipped to a third-party API.""" + name = filepath.name + if SENSITIVE_BASENAME_REGEX.match(name): + return True + # Normalize every component, not only basename: directories named + # `api-keys`, `private_keys`, or singular `secret` are equally sensitive. + normalized_parts = { + re.sub(r"[_\-\s.]", "", part.lower()) for part in filepath.parts + } + if normalized_parts & SENSITIVE_PATH_COMPONENTS: + return True + return any( + token in part + for part in normalized_parts + for token in SENSITIVE_NAME_TOKENS + ) + + +def strip_llm_wrapper(text: str) -> str: + r"""Strip an outer ```markdown ... ``` fence when it wraps the ENTIRE output. + + The wrapper is only real when the first and last fence lines are the SAME + block. The old regex (``\A\s*(fence)[^\n]*\n(.*)\n\1\s*\Z`` with DOTALL and + a greedy ``.*``) never checked that: it matched any document that merely + STARTS and ENDS with a fence line. An ordinary README section — + ```bash npm install``` , prose, ```bash npm test``` — came back with its + first and last fence markers deleted and its two code blocks merged into + prose, so validation failed on both the compress and the fix path and the + section was permanently uncompressible after three paid API calls. + """ + lines = text.split("\n") + first, last = 0, len(lines) - 1 + while first < len(lines) and not lines[first].strip(): + first += 1 + while last > first and not lines[last].strip(): + last -= 1 + if first >= last: + return text + opener = FENCE_LINE_REGEX.match(lines[first]) + closer = FENCE_LINE_REGEX.match(lines[last]) + if not opener or not closer: + return text + marker = opener.group(1) + # Closing fence: same character, at least as long, and nothing else on the line. + if closer.group(1)[0] != marker[0] or len(closer.group(1)) < len(marker): + return text + if lines[last].strip() != closer.group(1): + return text + # Any fence of the same kind in between means these two are not one block. + for line in lines[first + 1:last]: + inner = FENCE_LINE_REGEX.match(line) + if inner and inner.group(1)[0] == marker[0] and len(inner.group(1)) >= len(marker): + return text + return "\n".join(lines[first + 1:last]) + + +def write_text_atomic(path: Path, text: str, newline: str = "\n") -> None: + """Write ``text`` to ``path`` atomically as UTF-8. + + Path.write_text() truncates the destination before encoding the string — + a UnicodeEncodeError (or any other failure) partway through leaves a + 0-byte file, destroying whatever was there before (issue #655). Encode + first, write the bytes to a sibling temp file, fsync, then os.replace() + so the destination only ever moves from one complete, valid file to + another. Preserves the original file's permission bits across the swap. + + ``newline`` is the line terminator to emit. Callers pass the terminator + read_source() found in the source file so a CRLF document stays CRLF — + text-mode writes translating LF to the platform default rewrote every + line ending in every file the tool touched (issue #762), and reading the + bytes ourselves means nothing translates them back. + """ + if newline != "\n": + # Normalise first: model output can already carry CRLF, and a bare + # "\n" -> "\r\n" replace would turn those into "\r\r\n". + text = text.replace("\r\n", "\n").replace("\n", newline) + write_bytes_atomic(path, text.encode("utf-8")) + + +def write_bytes_atomic(path: Path, data: bytes) -> None: + """Write ``data`` to ``path`` atomically, preserving permission bits.""" + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=path.name + ".", suffix=".tmp" + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + if path.exists(): + os.chmod(tmp_path, stat.S_IMODE(path.stat().st_mode)) + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink() + except OSError: + pass + raise + + +def read_source(filepath: Path) -> tuple[str, str, bytes]: + """Read a source file as UTF-8, returning (text, line_terminator, raw_bytes). + + Decodes strictly. The old errors="ignore" silently DROPPED every byte that + was not valid UTF-8 — a cp1252-authored file holding `\xe9` for "e-acute" + lost that byte, the mangled text was what got written to the backup, the + backup readback compared mangled-to-mangled so verification passed, and + then the original was overwritten. The bytes were unrecoverable and + nothing reported a problem (the destructive form of issue #686). A file we + cannot read exactly is a file we must not rewrite. + + Line endings are detected from the raw bytes and returned to the caller + rather than being universal-newline'd away, so write_text_atomic can put + back what was there (issue #762). A mixed-ending file takes the terminator + the majority of its lines use — presence of one CRLF is not a mandate to + rewrite every LF in the document. The raw bytes come back too, so the + backup can be a byte-for-byte copy rather than a re-rendering. + """ + raw = filepath.read_bytes() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError( + f"Refusing to compress {filepath}: not valid UTF-8 " + f"(byte 0x{raw[e.start]:02x} at offset {e.start}). " + "Compression rewrites the file in place, and any byte this tool " + "cannot decode would be destroyed by the round trip. " + "Convert the file to UTF-8 first." + ) from None + crlf = text.count("\r\n") + newline = "\r\n" if crlf * 2 > text.count("\n") else "\n" + return text.replace("\r\n", "\n").replace("\r", "\n"), newline, raw + + +def first_nonblank_line(text: str) -> str: + """Return the first non-blank line, stripped — used to detect a prose + preamble smuggled in ahead of the real content (issue #588).""" + for line in text.splitlines(): + if line.strip(): + return line.strip() + return "" + + +def _write_target(filepath: Path, text: str | bytes, backup_path: Path, newline: str = "\n") -> None: + """Write to the target file, surfacing the backup location if the write + itself fails. write_text_atomic already leaves the target untouched on + failure, but the caller still needs to know where the pre-compression + original lives instead of being left to guess (issue #652). + + ``bytes`` restore the source verbatim; ``str`` is model output that still + has to be rendered with the document's line terminator.""" + try: + if isinstance(text, bytes): + write_bytes_atomic(filepath, text) + else: + write_text_atomic(filepath, text, newline) + except Exception: + print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}") + raise + + +from .detect import should_compress +from .validate import validate + +MAX_RETRIES = 2 + +# Bounds each individual Claude call so a stalled CLI (dropped network, an +# auth prompt with no TTY to answer it) can't hang past what LOCK_WAIT_SECONDS +# assumes for the whole run's worst case (MAX_RETRIES+1 calls). +CLAUDE_CALL_TIMEOUT_SECONDS = LOCK_WAIT_SECONDS // (MAX_RETRIES + 1) + + +# ---------- Claude Calls ---------- + + +def call_claude(prompt: str) -> str: + """Send a prompt to Claude. + + Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls + back to the ``claude --print`` CLI (which handles desktop auth). + + On Windows the CLI subprocess decoding defaults to the system codepage + (cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning + ``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual + native I/O and prevents the UnicodeDecodeError before validation can + report. Windows users with non-ASCII content can also set + ``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess. + """ + api_key = os.environ.get("ANTHROPIC_API_KEY") + if api_key: + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key, timeout=CLAUDE_CALL_TIMEOUT_SECONDS) + msg = client.messages.create( + model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"), + max_tokens=8192, + messages=[{"role": "user", "content": prompt}], + ) + # Tool-heavy models can put a tool_use or thinking block first; take + # the first text block instead of trusting content[0]. + text = next((block.text for block in msg.content if getattr(block, "type", None) == "text"), "") + return strip_llm_wrapper(text.strip()) + except ImportError: + pass # anthropic not installed, fall back to CLI + # Fallback: use claude CLI (handles desktop auth). + # Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g. + # %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX, + # shutil.which returns the same absolute path as the implicit lookup, + # so this is a no-op there. Falls back to bare "claude" if not found + # on PATH so subprocess raises a clear FileNotFoundError. + claude_bin = shutil.which("claude") or "claude" + try: + result = subprocess.run( + [ + claude_bin, + "--print", + "--setting-sources", + "", + "--strict-mcp-config", + ], + input=prompt, + text=True, + capture_output=True, + check=True, + encoding="utf-8", + errors="replace", + timeout=CLAUDE_CALL_TIMEOUT_SECONDS, + ) + return strip_llm_wrapper(result.stdout.strip()) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Claude call failed:\n{e.stderr}") + except subprocess.TimeoutExpired: + raise RuntimeError( + f"Claude CLI call timed out after {CLAUDE_CALL_TIMEOUT_SECONDS}s " + "(stalled network, or an auth prompt with no TTY to answer it)" + ) + + +def build_compress_prompt(original: str) -> str: + return f""" +Compress this markdown into caveman format. + +STRICT RULES: +- Do NOT modify anything inside ``` code blocks +- Do NOT modify anything inside a 4-space-indented code block either — those are code too, and they are validated +- Do NOT modify anything inside inline backticks +- Preserve ALL URLs exactly +- Preserve ALL headings exactly +- Preserve file paths and commands +- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. + +Only compress natural language. + +TEXT: +{original} +""" + + +def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: + errors_str = "\n".join(f"- {e}" for e in errors) + return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found. + +CRITICAL RULES: +- DO NOT recompress or rephrase the file +- ONLY fix the listed errors — leave everything else exactly as-is +- The ORIGINAL is provided as reference only (to restore missing content) +- Preserve caveman style in all untouched sections + +ERRORS TO FIX: +{errors_str} + +HOW TO FIX: +- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED +- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED +- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED +- Do not touch any section not mentioned in the errors + +ORIGINAL (reference only): +{original} + +COMPRESSED (fix this): +{compressed} + +Return ONLY the fixed compressed file. No explanation. +""" + + +CODE_MARKER_PREFIX = "@@CAVEMAN_PRESERVED_CODE_" +FENCE_OPEN_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(?:[^\r\n]*)$") + + +def mask_code_blocks(text: str) -> Tuple[str, List[Tuple[str, str]]]: + """Replace fenced and four-space-indented code with opaque line markers.""" + if CODE_MARKER_PREFIX in text: + raise ValueError("Input contains reserved Caveman code-preservation marker") + lines = text.splitlines(keepends=True) + out: List[str] = [] + blocks: List[Tuple[str, str]] = [] + i = 0 + while i < len(lines): + line_without_newline = lines[i].rstrip("\r\n") + fence = FENCE_OPEN_RE.match(line_without_newline) + indented = bool(line_without_newline) and ( + line_without_newline.startswith(" ") or line_without_newline.startswith("\t") + ) + if not fence and not indented: + out.append(lines[i]) + i += 1 + continue + + start = i + if fence: + fence_run = fence.group(1) + close_re = re.compile( + rf"^[ ]{{0,3}}{re.escape(fence_run[0])}{{{len(fence_run)},}}[ \t]*$" + ) + i += 1 + while i < len(lines): + if close_re.match(lines[i].rstrip("\r\n")): + i += 1 + break + i += 1 + else: + i += 1 + while i < len(lines): + candidate = lines[i].rstrip("\r\n") + if not candidate or candidate.startswith(" ") or candidate.startswith("\t"): + i += 1 + continue + break + + block = "".join(lines[start:i]) + marker = f"{CODE_MARKER_PREFIX}{len(blocks)}_{hashlib.sha256(block.encode('utf-8')).hexdigest()[:16]}@@" + blocks.append((marker, block)) + newline = "\r\n" if block.endswith("\r\n") else "\n" if block.endswith("\n") else "" + out.append(marker + newline) + return "".join(out), blocks + + +def restore_code_blocks(text: str, blocks: List[Tuple[str, str]]) -> str: + """Restore markers exactly; fail closed if model removed, copied, or altered one.""" + restored = text + for marker, block in blocks: + if restored.count(marker) != 1: + raise ValueError( + f"Claude changed preserved code marker {marker}; refusing to write" + ) + # Masking gives marker its own transport newline. Consume that wrapper + # when present so restoring a block that already ended in newline does + # not silently add another blank line. + if marker + "\r\n" in restored: + restored = restored.replace(marker + "\r\n", block, 1) + elif marker + "\n" in restored: + restored = restored.replace(marker + "\n", block, 1) + else: + restored = restored.replace(marker, block, 1) + if CODE_MARKER_PREFIX in restored: + raise ValueError("Claude returned an unknown Caveman code-preservation marker") + return restored + + +# ---------- Core Logic ---------- + + +def compress_file(filepath: Path) -> bool: + # Resolve first so the lock and every check below key off the same canonical path regardless of how the caller spelled it. + filepath = filepath.resolve() + + MAX_FILE_SIZE = 500_000 # 500KB + # None of these three checks depends on mutual exclusion, so they run before the lock is taken — a rejected input (bad path, oversized, sensitive name) shouldn't leave a permanent lock file behind in shared state. + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + if filepath.stat().st_size > MAX_FILE_SIZE: + raise ValueError(f"File too large to compress safely (max 500KB): {filepath}") + + # Refuse files that look like they contain secrets or PII. Compressing ships + # the raw bytes to the Anthropic API — a third-party boundary — so we fail + # loudly rather than silently exfiltrate credentials or keys. Override is + # intentional: the user must rename the file if the heuristic is wrong. + if is_sensitive_path(filepath): + raise ValueError( + f"Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Compression sends file contents to the Anthropic API. " + "Rename the file if this is a false positive." + ) + + with file_lock(filepath): + return _compress_file_locked(filepath) + + +def _compress_file_locked(filepath: Path) -> bool: + """Body of compress_file; runs entirely under compress_file's file_lock for this resolved path.""" + print(f"Processing: {filepath}") + + if not should_compress(filepath): + print("Skipping (not natural language)") + return False + + original_text, newline, original_raw = read_source(filepath) + # Store backup outside the source directory so skill auto-loaders don't + # re-ingest the `.original.md` copy as a live file. Mirror the source's + # parent-dir name + stem under a platform-aware base to reduce collisions. + backup_dir = backup_dir_for(filepath) + backup_path = backup_dir / (filepath.stem + ".original.md") + + if not original_text.strip(): + print("❌ Refusing to compress: file is empty or whitespace-only.") + return False + + # Check if backup already exists to prevent accidental overwriting + if backup_path.exists(): + print(f"⚠️ Backup file already exists: {backup_path}") + print("The original backup may contain important content.") + print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + return False + + # Split YAML frontmatter off before compression. Claude tends to strip or + # rewrite frontmatter despite preserve-structure rules; we keep it verbatim + # by removing it from the input and re-prepending it to the output. + frontmatter, body = split_frontmatter(original_text) + if frontmatter: + print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim") + + if not body.strip(): + print("❌ Refusing to compress: body is empty after frontmatter removal.") + return False + + # Step 1: Compress (body only, frontmatter excluded) + print("Compressing with Claude...") + masked_body, code_blocks = mask_code_blocks(body) + masked_compressed = call_claude(build_compress_prompt(masked_body)) + try: + compressed_body = restore_code_blocks(masked_compressed, code_blocks) + except ValueError as error: + print(f"❌ Compression aborted: {error}") + print(" Original file is untouched (no backup created).") + return False + + if compressed_body is None or not compressed_body.strip(): + print("❌ Compression aborted: Claude returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + + # Compare the BODY (not the whole file) — frontmatter is preserved verbatim + # and would never change, so identity must be judged on the compressible part. + if compressed_body.strip() == body.strip(): + print("❌ Compression aborted: output is identical to input.") + print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is") + print(" already in caveman form. Original file is untouched (no backup created).") + return False + + # Reassemble: frontmatter (verbatim) + compressed body + compressed = frontmatter + compressed_body + + # Save original as backup, then verify the backup readback before + # touching the input file. If the filesystem dropped bytes (encoding, + # antivirus, disk full), unlink the bad backup and abort instead of + # leaving the user with a corrupt backup + compressed primary. + backup_dir.mkdir(parents=True, exist_ok=True) + write_bytes_atomic(backup_path, original_raw) + if backup_path.read_bytes() != original_raw: + print(f"❌ Backup write verification failed: {backup_path}") + print(" In-memory original differs from on-disk backup. Aborting before touching the input file.") + try: + backup_path.unlink() + except OSError: + pass + return False + # Step 2: Validate + Retry. Each candidate is staged and validated next + # to the source; the live file is written only once one passes (#544). + staging_path = filepath.with_name(filepath.name + ".caveman-staged") + for attempt in range(MAX_RETRIES): + print(f"\nValidation attempt {attempt + 1}") + + _write_target(staging_path, compressed, backup_path, newline) + result = validate(backup_path, staging_path) + + if result.is_valid: + print("Validation passed") + _write_target(filepath, compressed, backup_path, newline) + staging_path.unlink(missing_ok=True) + return True + + print("❌ Validation failed:") + for err in result.errors: + print(f" - {err}") + + if attempt == MAX_RETRIES - 1: + staging_path.unlink(missing_ok=True) + backup_path.unlink(missing_ok=True) + print("Failed after retries: original left untouched") + return False + + print("Fixing with Claude...") + fixed = call_claude( + build_fix_prompt(original_text, compressed, result.errors) + ) + + if fixed is None or not fixed.strip(): + print("❌ Fix attempt aborted: Claude returned an empty response.") + print(" Skipping this attempt.") + continue + + # Guard against a prose preamble smuggled in ahead of the real fixed + # content (issue #588). Only enforced when the original starts with a + # structural anchor (frontmatter `---` or a heading) — plain-prose + # first lines get legitimately rewritten by compression, and requiring + # them verbatim would reject every valid fix. + anchor = first_nonblank_line(original_text) + if anchor.startswith(("---", "#")) and first_nonblank_line(fixed) != anchor: + print("❌ Fix attempt aborted: output does not start with the original's first line.") + print(" Possible preamble leak. Skipping this attempt.") + continue + + compressed = fixed + + return False diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py new file mode 100644 index 00000000..f51e6b70 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Detect whether a file is natural language (compressible) or code/config (skip).""" + +import json +import re +from pathlib import Path + +# Extensions that are natural language and compressible +COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} + +# Extensions that are code/config and should be skipped +SKIP_EXTENSIONS = { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", + ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml", + ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c", + ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua", + ".dockerfile", ".makefile", ".csv", ".ini", ".cfg", +} + +# Well-known build/config files that carry no (or a misleading) extension — +# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and +# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by +# basename before any extension rule. +KNOWN_CODE_FILENAMES = { + "dockerfile", "containerfile", "makefile", "gnumakefile", "jenkinsfile", + "vagrantfile", "rakefile", "gemfile", "justfile", "procfile", "brewfile", + "earthfile", "fastfile", "podfile", + "cmakelists.txt", +} + +# Patterns that indicate a line is code +CODE_PATTERNS = [ + re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"), + re.compile(r"^\s*(def |class |function |async function |export )"), + re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"), + re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets + re.compile(r"^\s*@\w+"), # decorators/annotations + re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value + re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal +] + + +def _is_code_line(line: str) -> bool: + """Check if a line looks like code.""" + return any(p.match(line) for p in CODE_PATTERNS) + + +def _is_json_content(text: str) -> bool: + """Check if content is valid JSON.""" + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + return False + + +def _is_yaml_content(lines: list[str]) -> bool: + """Heuristic: check if content looks like YAML.""" + yaml_indicators = 0 + for line in lines[:30]: + stripped = line.strip() + if stripped.startswith("---"): + yaml_indicators += 1 + elif re.match(r"^\w[\w\s]*:\s", stripped): + yaml_indicators += 1 + elif stripped.startswith("- ") and ":" in stripped: + yaml_indicators += 1 + # If most non-empty lines look like YAML + non_empty = sum(1 for l in lines[:30] if l.strip()) + return non_empty > 0 and yaml_indicators / non_empty > 0.6 + + +def detect_file_type(filepath: Path) -> str: + """Classify a file as 'natural_language', 'code', 'config', or 'unknown'. + + Returns: + One of: 'natural_language', 'code', 'config', 'unknown' + """ + ext = filepath.suffix.lower() + + # Known code filenames win over any extension rule + if filepath.name.lower() in KNOWN_CODE_FILENAMES: + return "code" + + # Extension-based classification + if ext in COMPRESSIBLE_EXTENSIONS: + return "natural_language" + if ext in SKIP_EXTENSIONS: + return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config" + + # Extensionless files (like CLAUDE.md, TODO) — check content + if not ext: + try: + text = filepath.read_text(encoding="utf-8", errors="ignore") + except (OSError, PermissionError): + return "unknown" + + lines = text.splitlines()[:50] + + # Shebang means executable script, never prose + if text.startswith("#!"): + return "code" + + if _is_json_content(text[:10000]): + return "config" + if _is_yaml_content(lines): + return "config" + + code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l)) + non_empty = sum(1 for l in lines if l.strip()) + if non_empty > 0 and code_lines / non_empty > 0.4: + return "code" + + return "natural_language" + + return "unknown" + + +def should_compress(filepath: Path) -> bool: + """Return True if the file is natural language and should be compressed.""" + if not filepath.is_file(): + return False + # Skip backup files + if filepath.name.endswith(".original.md"): + return False + return detect_file_type(filepath) == "natural_language" + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python detect.py [file2] ...") + sys.exit(1) + + for path_str in sys.argv[1:]: + p = Path(path_str).resolve() + file_type = detect_file_type(p) + compress = should_compress(p) + print(f" {p.name:30s} type={file_type:20s} compress={compress}") diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py new file mode 100644 index 00000000..29d156a2 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +import re +from collections import Counter +from pathlib import Path + +URL_REGEX = re.compile(r"https?://[^\s)]+") +FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$") + +# A line that is nothing but a fence marker plus an optional info string, at ANY +# indentation. Used ONLY to scrub leaked markers before inline-code pairing (see +# extract_inline_codes) — never for block extraction. +# +# Widening FENCE_OPEN_REGEX itself to `\s*` looks like the obvious fix for #820 +# and is a net regression: a lone indented ``` (the natural way to SHOW a fence +# inside prose) then opens a block that runs to EOF, swallowing real code blocks +# and silently removing their inline spans from validation. That turns a +# false-failure bug into a false-PASS bug, and a false PASS overwrites the +# user's file with unvalidated output. +FENCE_MARKER_LINE_REGEX = re.compile(r"^\s*(?:`{3,}|~{3,})[^`~]*$") + +# Cap on how much of a lost/added span is echoed in an error message. Unpaired +# backticks can make a "span" hundreds of characters of prose; printing it whole +# is what made #820's failures undiagnosable. +MAX_REPORTED_SPAN = 60 +HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE) +BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE) +# Any list item, ordered or not. Four spaces inside a list item is the item's +# content indentation, never an indented code block. +LIST_ITEM_REGEX = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s") + +# crude but effective path detection +# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match +PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+") + +# PATH_REGEX is crude on purpose and also matches ordinary prose pairs — +# "pros/cons", "Node/browser", "state/lifecycle". Caveman prose ADDS those +# constructions freely and dropping one breaks nothing, so only an unambiguous +# path — a leading ./ ../ / or drive letter, or a dotted filename in the last +# component — is treated as a hard loss. +DEFINITE_PATH_REGEX = re.compile(r"^(?:\./|\.\./|/|[A-Za-z]:\\)|[^/\\]*\.[A-Za-z0-9]{1,8}$") + + +class ValidationResult: + def __init__(self): + self.is_valid = True + self.errors = [] + self.warnings = [] + + def add_error(self, msg): + self.is_valid = False + self.errors.append(msg) + + def add_warning(self, msg): + self.warnings.append(msg) + + +def read_file(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +# ---------- Extractors ---------- + + +def extract_headings(text): + return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)] + + +def extract_code_blocks(text): + """Line-based fenced code block extractor. + + Handles ``` and ~~~ fences with variable length (CommonMark: closing + fence must use same char and be at least as long as opening). Supports + nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick + content). + """ + blocks = [] + lines = text.split("\n") + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + open_line = lines[i] + block_lines = [open_line] + i += 1 + closed = False + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + block_lines.append(lines[i]) + closed = True + i += 1 + break + block_lines.append(lines[i]) + i += 1 + if closed: + blocks.append("\n".join(block_lines)) + # Unclosed fences are silently skipped — they indicate malformed markdown + # and including them would cause false-positive validation failures. + return blocks + extract_indented_code_blocks(text) + + +def extract_indented_code_blocks(text): + """CommonMark indented code blocks — 4-space-indented runs outside any fence. + + Without these, ` kubectl delete pod --all -n prod` was prose to the + validator: "code blocks preserved exactly" compared empty to empty and + PASSED while the compressor rewrote the command to + `kubectl delete pod -n dev`. A clean pass on a mutated destructive command + is the worst failure this tool has, because it overwrites the user's file. + + Deliberately conservative about lists: inside a list item, four spaces are + the item's content indentation, not code, and nested bullets are ordinary + prose the compressor SHOULD rewrite. A run is only treated as code when the + document is not inside a list and the run is preceded by a blank line — so + this adds detections, it never turns existing passes into false failures. + """ + blocks = [] + lines = text.split("\n") + fenced = set() + for block in extract_fenced_spans(lines): + fenced.update(block) + in_list = False + previous_blank = True + i = 0 + n = len(lines) + while i < n: + line = lines[i] + stripped = line.strip() + if i in fenced: + in_list, previous_blank = in_list, False + i += 1 + continue + if not stripped: + previous_blank = True + i += 1 + continue + indent = len(line) - len(line.lstrip(" \t")) + if LIST_ITEM_REGEX.match(line): + in_list = True + elif indent == 0: + in_list = False + if not in_list and previous_blank and indent >= 4: + run = [] + while i < n and i not in fenced: + current = lines[i] + if not current.strip(): + # A blank line continues an indented block only if more + # indented content follows. + lookahead = i + 1 + while lookahead < n and not lines[lookahead].strip(): + lookahead += 1 + if lookahead < n and lookahead not in fenced and \ + len(lines[lookahead]) - len(lines[lookahead].lstrip(" \t")) >= 4: + run.extend(lines[i:lookahead]) + i = lookahead + continue + break + if len(current) - len(current.lstrip(" \t")) < 4: + break + run.append(current) + i += 1 + if run: + blocks.append("\n".join(run)) + previous_blank = False + continue + previous_blank = False + i += 1 + return blocks + + +def extract_fenced_spans(lines): + """Line-index ranges covered by fenced blocks, so indented-code detection + never reaches inside one.""" + spans = [] + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + start = i + i += 1 + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + i += 1 + break + i += 1 + spans.append(range(start, i)) + return spans + + +def extract_urls(text): + return set(URL_REGEX.findall(text)) + + +def extract_paths(text): + return set(PATH_REGEX.findall(text)) + + +def count_bullets(text): + return len(BULLET_REGEX.findall(text)) + + +def extract_inline_codes(text): + """Backtick-delimited inline spans, with fenced code blocks stripped first. + + Previously used a column-0-anchored regex to strip fences, which misses + fences indented 1-3 spaces (valid CommonMark). Reuse extract_code_blocks + (FENCE_OPEN_REGEX-based, indentation-aware) instead so an indented fence's + body backticks don't leak into inline-code pairing. + + Any fence-marker line that survives that pass is then blanked (#820). A + fence indented 4+ spaces — what you get from showing an example inside a + bullet — is not matched by FENCE_OPEN_REGEX, so extract_code_blocks does + not remove it and its OWN backticks used to leak in and shift the pairing + of every following span, making the file permanently uncompressible. + Blanking just the marker lines fixes that without removing any prose, and + cannot run away the way a widened fence opener does. + + The span pattern deliberately still spans newlines. CommonMark permits a + line ending inside a code span and hard-wrapped markdown produces them, so + a single-line pattern silently drops real spans — which downgrades a + deleted or mutated span from error to PASS. Long/garbled spans are a + presentation problem, handled by truncating in the error message instead. + """ + text_without_fences = text + for block in extract_code_blocks(text): + text_without_fences = text_without_fences.replace(block, "", 1) + text_without_fences = "\n".join( + "" if FENCE_MARKER_LINE_REGEX.match(line) else line + for line in text_without_fences.split("\n") + ) + return re.findall(r"`([^`]+)`", text_without_fences) + + +# ---------- Validators ---------- + + +def validate_headings(orig, comp, result): + h1 = extract_headings(orig) + h2 = extract_headings(comp) + + # Changed heading TEXT is an error, not a warning. Every in-document anchor + # link points at a heading's slug, so renaming "# Configuration Options" to + # "# Config" silently breaks all of them — and SKILL.md and CLAUDE.md both + # state headings are preserved. Only counts used to gate the overwrite, so + # a run that renamed every heading reported "Validation passed". A level-only + # change keeps every slug intact and stays a warning. + if len(h1) != len(h2): + result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}") + return + + t1 = [text for _, text in h1] + t2 = [text for _, text in h2] + if t1 != t2: + lost = [t for t in t1 if t not in t2] + added = [t for t in t2 if t not in t1] + result.add_error(f"Heading text/order changed: lost={lost}, added={added}") + elif h1 != h2: + # Same text, different level. The outline moved but no anchor broke — + # slugs come from the text, so links still resolve. + result.add_warning("Heading levels changed") + + +def validate_code_blocks(orig, comp, result): + c1 = extract_code_blocks(orig) + c2 = extract_code_blocks(comp) + + if c1 != c2: + result.add_error("Code blocks not preserved exactly") + + +def validate_urls(orig, comp, result): + u1 = extract_urls(orig) + u2 = extract_urls(comp) + + if u1 != u2: + result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}") + + +def validate_paths(orig, comp, result): + """File paths are preserved — an error, never a warning. + + This validator never called add_error at all, so a compressed file that had + dropped `src/hooks/caveman-config.js` still reported "Validation passed" and + the in-place overwrite stood. SKILL.md and CLAUDE.md both promise paths + survive compression; nothing enforced it. + """ + p1 = extract_paths(orig) + p2 = extract_paths(comp) + lost = p1 - p2 + added = p2 - p1 + + definite = {p for p in lost if DEFINITE_PATH_REGEX.search(p)} + if definite: + result.add_error(f"File paths lost: {sorted(definite)}") + if (lost - definite) or added: + result.add_warning(f"Path mismatch: lost={sorted(lost)}, added={sorted(added)}") + + +def validate_bullets(orig, comp, result): + b1 = count_bullets(orig) + b2 = count_bullets(comp) + + if b1 == 0: + return + + diff = abs(b1 - b2) / b1 + + if diff > 0.15: + result.add_warning(f"Bullet count changed too much: {b1} -> {b2}") + + +def validate_inline_codes(orig, comp, result): + def _render_spans(spans): + """Render spans for an error message, truncated and newline-escaped. + + A span may legitimately contain newlines, and an unpaired backtick can + make one hundreds of characters of prose. Printing those whole is what + made #820's failures undiagnosable — but the fix belongs here, in + presentation, not in what counts as a span. + """ + out = [] + for span in sorted(spans): + flat = span.replace("\n", "\\n") + if len(flat) > MAX_REPORTED_SPAN: + flat = flat[:MAX_REPORTED_SPAN] + "…" + out.append(repr(flat)) + return "{" + ", ".join(out) + "}" + + c1 = Counter(extract_inline_codes(orig)) + c2 = Counter(extract_inline_codes(comp)) + + if c1 != c2: + lost = set(c1.keys()) - set(c2.keys()) + added = set(c2.keys()) - set(c1.keys()) + for code, count in c1.items(): + if code in c2 and c2[code] < count: + lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)") + if lost: + result.add_error(f"Inline code lost: {_render_spans(lost)}") + if added: + result.add_warning(f"Inline code added: {_render_spans(added)}") + + +# ---------- Main ---------- + + +def validate(original_path: Path, compressed_path: Path) -> ValidationResult: + result = ValidationResult() + + orig = read_file(original_path) + comp = read_file(compressed_path) + + validate_headings(orig, comp, result) + validate_code_blocks(orig, comp, result) + validate_urls(orig, comp, result) + validate_paths(orig, comp, result) + validate_bullets(orig, comp, result) + validate_inline_codes(orig, comp, result) + + return result + + +# ---------- CLI ---------- + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 3: + print("Usage: python validate.py ") + sys.exit(1) + + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + + res = validate(orig, comp) + + print(f"\nValid: {res.is_valid}") + + if res.errors: + print("\nErrors:") + for e in res.errors: + print(f" - {e}") + + if res.warnings: + print("\nWarnings:") + for w in res.warnings: + print(f" - {w}") diff --git a/plugins/codex/plugins/caveman/skills/caveman-review/SKILL.md b/plugins/codex/plugins/caveman/skills/caveman-review/SKILL.md new file mode 100644 index 00000000..7ad1488a --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/caveman-review/SKILL.md @@ -0,0 +1,51 @@ +--- +name: caveman-review +description: "以文件和行号为中心输出简短、可执行的代码评审发现。" +--- + +Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing. + +## Rules + +**Format:** `L: . .` — or `:L: ...` when reviewing multi-file diffs. + +**Severity prefix (optional, when mixed):** +- `🔴 bug:` — broken behavior, will cause incident +- `🟡 risk:` — works but fragile (race, missing null check, swallowed error) +- `🔵 nit:` — style, naming, micro-optim. Author can ignore +- `❓ q:` — genuine question, not a suggestion + +**Drop:** +- "I noticed that...", "It seems like...", "You might want to consider..." +- "This is just a suggestion but..." — use `nit:` instead +- "Great work!", "Looks good overall but..." — say it once at the top, not per comment +- Restating what the line does — the reviewer can read the diff +- Hedging ("perhaps", "maybe", "I think") — if unsure use `q:` + +**Keep:** +- Exact line numbers +- Exact symbol/function/variable names in backticks +- Concrete fix, not "consider refactoring this" +- The *why* if the fix isn't obvious from the problem statement + +## Examples + +❌ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This could potentially cause a crash if the user is not found in the database. You might want to add a null check here." + +✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.` + +❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability." + +✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.` + +❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case." + +✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).` + +## Auto-Clarity + +Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those cases write a normal paragraph, then resume terse for the rest. + +## Boundaries + +Reviews only — does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style. diff --git a/plugins/codex/plugins/caveman/skills/investigate-first/SKILL.md b/plugins/codex/plugins/caveman/skills/investigate-first/SKILL.md new file mode 100644 index 00000000..dbb74f51 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/investigate-first/SKILL.md @@ -0,0 +1,16 @@ +--- +name: investigate-first +description: "在修改代码前收集证据、排列假设并确定可信根因。" +--- + +# Investigate first + +Gather evidence before changing product code. + +- Separate observed symptom from inferred cause. +- Trace inputs, state transitions, ownership boundaries, and failure output. +- Rank hypotheses by evidence and cheap falsification value. +- Do not edit until one credible mechanism explains evidence. +- Stop exploration when evidence is sufficient to name cause or exact blocker. + +Report cause and proof. Make no fix unless task authorizes implementation. diff --git a/plugins/codex/plugins/caveman/skills/investigate-first/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/investigate-first/agents/openai.yaml new file mode 100644 index 00000000..f30303a8 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/investigate-first/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Investigate First" + short_description: "Find credible cause before editing code" + default_prompt: "Use $investigate-first to diagnose this failure before proposing edits." diff --git a/plugins/codex/plugins/caveman/skills/lean-build/SKILL.md b/plugins/codex/plugins/caveman/skills/lean-build/SKILL.md new file mode 100644 index 00000000..49ac94a8 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/lean-build/SKILL.md @@ -0,0 +1,18 @@ +--- +name: lean-build +description: "以最小完整范围实现功能,并以明确验收条件约束扩展。" +--- + +# Lean build + +Native Core's architecture-first simplicity remains mandatory. Turn feature into complete narrow outcome fitting system. + +- Derive observable acceptance and explicit non-goals from request and repository. +- Trace entry point through layers owning invariants. +- Deliver coherent end-to-end path across responsible layers; never force work into one file, direct expression, or local patch. +- Reuse fitting seam. Refactor when patching duplicates behavior, weakens ownership, or hides root cause. +- Omit modes, providers, config, extensibility, and polish unless acceptance needs them. +- Add surface, dependency, service, config, or migration only for lifecycle design or acceptance; state material tradeoff. +- Keep work runnable; preserve Core safety. + +Exercise path. Run focused proof. Stop when acceptance passes. Report only material omissions and trigger. diff --git a/plugins/codex/plugins/caveman/skills/lean-build/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/lean-build/agents/openai.yaml new file mode 100644 index 00000000..2dc1f0f0 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/lean-build/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Lean Build" + short_description: "Build smallest coherent feature slice" + default_prompt: "Use $lean-build to implement this feature without speculative scope." diff --git a/plugins/codex/plugins/caveman/skills/migration/SKILL.md b/plugins/codex/plugins/caveman/skills/migration/SKILL.md new file mode 100644 index 00000000..e2289863 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/migration/SKILL.md @@ -0,0 +1,17 @@ +--- +name: migration +description: "规划并实施可回滚、兼容且保护现有数据的迁移。" +--- + +# Migration + +Map current readers, writers, data shape, compatibility window, and ownership before editing. + +- Define forward path and rollback path. +- Preserve existing data; make destructive steps explicit and separately authorized. +- Keep mixed-version operation safe where rollout can overlap. +- Sequence expand, migrate, verify, then contract when applicable. +- Make retries idempotent and partial failure observable. +- Verify old and new paths at required transition stages. + +Stop after requested stage passes; do not perform later destructive contraction implicitly. diff --git a/plugins/codex/plugins/caveman/skills/migration/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/migration/agents/openai.yaml new file mode 100644 index 00000000..d04b219c --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/migration/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migration" + short_description: "Plan reversible data-safe transitions" + default_prompt: "Use $migration to implement this transition with compatibility and rollback proof." diff --git a/plugins/codex/plugins/caveman/skills/safe-refactor/SKILL.md b/plugins/codex/plugins/caveman/skills/safe-refactor/SKILL.md new file mode 100644 index 00000000..c999d096 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/safe-refactor/SKILL.md @@ -0,0 +1,16 @@ +--- +name: safe-refactor +description: "在保持外部行为不变的前提下进行结构调整并验证结果。" +--- + +# Safe refactor + +Define behavior-preservation boundary and establish verification before structural edits. + +- Keep feature changes outside refactor. +- Move one ownership boundary at a time. +- Preserve public interfaces, failure behavior, ordering, and compatibility unless explicitly scoped. +- Keep intermediate states buildable and testable. +- Avoid dependency or configuration growth without correctness need. + +Run same proof after change. Stop when behavior matches and requested structure is achieved. diff --git a/plugins/codex/plugins/caveman/skills/safe-refactor/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/safe-refactor/agents/openai.yaml new file mode 100644 index 00000000..19606d55 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/safe-refactor/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Safe Refactor" + short_description: "Preserve behavior through structural change" + default_prompt: "Use $safe-refactor to restructure this code while preserving behavior." diff --git a/plugins/codex/plugins/caveman/skills/surgical-patch/SKILL.md b/plugins/codex/plugins/caveman/skills/surgical-patch/SKILL.md new file mode 100644 index 00000000..744043ab --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/surgical-patch/SKILL.md @@ -0,0 +1,16 @@ +--- +name: surgical-patch +description: "在职责归属最窄的层修复缺陷,并提供针对性回归验证。" +--- + +# Surgical patch + +Reproduce failure first when economical; otherwise capture strongest available evidence. + +- Trace symptom to responsible mechanism. +- Change narrowest layer that owns incorrect behavior. +- Preserve unrelated behavior and user changes. +- Avoid cleanup, renaming, and abstraction outside fix. +- Add only regression proof relevant to task. + +Run focused proof plus nearest affected gate. Stop when failure is fixed and regression proof passes. diff --git a/plugins/codex/plugins/caveman/skills/surgical-patch/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/surgical-patch/agents/openai.yaml new file mode 100644 index 00000000..d539722c --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/surgical-patch/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Surgical Patch" + short_description: "Fix narrow responsible layer with proof" + default_prompt: "Use $surgical-patch to fix this bug with a narrow verified change." diff --git a/plugins/codex/plugins/caveman/skills/verify-and-stop/SKILL.md b/plugins/codex/plugins/caveman/skills/verify-and-stop/SKILL.md new file mode 100644 index 00000000..0ef5261b --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/verify-and-stop/SKILL.md @@ -0,0 +1,16 @@ +--- +name: verify-and-stop +description: "运行满足验收条件所需的最小验证集,避免额外扩大范围。" +--- + +# Verify and stop + +Translate acceptance conditions into smallest sufficient proof set. + +- Reuse still-current results with matching repository state. +- Run focused checks before wider gates. +- Distinguish pass, fail, unavailable, and blocked exactly. +- Do not edit product code unless verification request includes fixes. +- Do not add polish, cleanup, or unrelated tests after criteria pass. + +Stop immediately when acceptance proof is complete. Report commands, results, and unresolved risk only. diff --git a/plugins/codex/plugins/caveman/skills/verify-and-stop/agents/openai.yaml b/plugins/codex/plugins/caveman/skills/verify-and-stop/agents/openai.yaml new file mode 100644 index 00000000..bf3a9261 --- /dev/null +++ b/plugins/codex/plugins/caveman/skills/verify-and-stop/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Verify and Stop" + short_description: "Run relevant proof then stop cleanly" + default_prompt: "Use $verify-and-stop to prove acceptance criteria without adding scope." diff --git a/scripts/sync_external_plugins.ps1 b/scripts/sync_external_plugins.ps1 index b3dcf2ce..74e78806 100644 --- a/scripts/sync_external_plugins.ps1 +++ b/scripts/sync_external_plugins.ps1 @@ -785,11 +785,45 @@ function Sync-CodexPlugin { Copy-PathIfExists -Source $src -Destination $dst -Within $RepoRoot } Copy-CodexManifestDeclaredPaths -SourceRoot $sourcePath -PluginDir $pluginDir -Within $RepoRoot + $materializePaths = @() + if ($Source.PSObject.Properties.Name -contains "materializePaths" -and $Source.materializePaths) { + $materializePaths = @($Source.materializePaths) + } + foreach ($materialize in $materializePaths) { + $materializeSourceRelative = [string]$materialize.source + $materializeDestinationRelative = [string]$materialize.destination + Assert-RelativePathValue -Path $materializeSourceRelative + Assert-RelativePathValue -Path $materializeDestinationRelative + $materializeSource = Join-Path $ClonePath $materializeSourceRelative + $materializeDestination = Join-Path $pluginDir $materializeDestinationRelative + if (-not (Test-Path -LiteralPath $materializeSource)) { + throw "materializePaths source does not exist: $materializeSource" + } + Remove-PathIfExists -Path $materializeDestination -Within $RepoRoot + Copy-PathIfExists -Source $materializeSource -Destination $materializeDestination -Within $RepoRoot + } Remove-ConfiguredExcludes -Root $pluginDir -ExcludePaths $Source.excludePaths -Within $RepoRoot Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths Apply-LocalOverrides -PluginDir $pluginDir -Source $Source + if ($Source.PSObject.Properties.Name -contains "manifestVersionPath" -and $Source.manifestVersionPath) { + $versionSourceRelative = [string]$Source.manifestVersionPath + Assert-RelativePathValue -Path $versionSourceRelative + $versionSourcePath = Join-Path $ClonePath $versionSourceRelative + if (-not (Test-Path -LiteralPath $versionSourcePath -PathType Leaf)) { + throw "manifestVersionPath does not exist: $versionSourcePath" + } + $versionMetadata = Read-JsonFile $versionSourcePath + $version = [string]$versionMetadata.version + if ($version -notmatch '^\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$') { + throw "manifestVersionPath does not contain a valid semver version: $versionSourcePath" + } + $outputManifestPath = Join-Path $pluginDir ".codex-plugin\plugin.json" + $outputManifest = ConvertTo-Hashtable (Read-JsonFile $outputManifestPath) + $outputManifest.version = $version + Write-JsonFile -Path $outputManifestPath -Value $outputManifest + } Write-Provenance -PluginDir $pluginDir -Source $Source -Commit $Commit -SyncedAt $SyncedAt -SourcePath $relativeSourcePath }