Update Caveman Codex plugin integration

This commit is contained in:
KeyInfo Bot
2026-09-03 11:27:57 +08:00
parent cdfe190697
commit e780ed6213
26 changed files with 1887 additions and 13 deletions
+2
View File
@@ -29,6 +29,8 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ
部分插件依赖特定运行环境。`$updating-gitea-repositories` 面向 Gitea HTTPS、GCM 和 PAT 工作流;`$etunel-role-collaboration` 仅在 Etunel 提供角色、成员、Hook 和任务上下文时使用。安装前应查看插件详情并确认运行前置条件。 部分插件依赖特定运行环境。`$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 配置。 如果插件刚安装到当前 Codex 会话中,启动新的 Codex thread 或新的 Codex 调用后再依赖新增 skills/hooks/MCP/app 配置。
卸载插件: 卸载插件:
+61 -8
View File
@@ -166,7 +166,16 @@
"category": "Developer Tools", "category": "Developer Tools",
"sourcePath": "plugins/caveman", "sourcePath": "plugins/caveman",
"sparseCheckout": [ "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": [ "include": [
".codex-plugin", ".codex-plugin",
@@ -175,15 +184,49 @@
"agents" "agents"
], ],
"excludePaths": [ "excludePaths": [
"skills/caveman-stats", "skills/caveman-stats"
"skills/caveman-compress"
], ],
"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": { "manifestOverrides": {
"description": "Caveman 是面向 Codex 的极简输出风格和压缩式协作 skill 集合。", "description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
"interface": { "interface": {
"displayName": "Caveman", "displayName": "Caveman",
"shortDescription": " Codex 输出更短,同时保留技术准确性。", "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。",
"longDescription": "Caveman 提供简输出风格和压缩式子 Agent 协作指引。此公共市场版本只发布 Codex plugin 内可直接使用的 skills;不包含上游一键安装器、Claude Code hooks/statusline、MCP shrink、根目录 slash commands,以及依赖 Anthropic API 或 Claude CLI 的压缩脚本 skill。", "longDescription": "Caveman 提供简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。另含可选的 caveman-compress;它需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooksstatusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。",
"developerName": "Julius Brussee", "developerName": "Julius Brussee",
"category": "Developer Tools", "category": "Developer Tools",
"capabilities": [ "capabilities": [
@@ -192,7 +235,8 @@
], ],
"defaultPrompt": [ "defaultPrompt": [
"使用 Caveman 模式回答,压缩表达但保留技术准确性。", "使用 Caveman 模式回答,压缩表达但保留技术准确性。",
"使用 Cavecrew 帮我判断这个任务是否适合分派给压缩输出的子 Agent。" "使用 investigate-first 先调查这个故障的可信根因。",
"使用 surgical-patch 对这个问题做最小且经过验证的修复。"
], ],
"websiteURL": "https://github.com/JuliusBrussee/caveman", "websiteURL": "https://github.com/JuliusBrussee/caveman",
"privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md", "privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md",
@@ -202,7 +246,16 @@
}, },
"skillDescriptions": { "skillDescriptions": {
"caveman": "让 Codex 使用更短、更直接的表达,同时保留代码、命令、错误和技术细节。", "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": "运行满足验收条件所需的最小验证集,避免额外扩大范围。"
} }
}, },
{ {
@@ -1,7 +1,7 @@
{ {
"name": "caveman", "name": "caveman",
"version": "0.1.0", "version": "2.5.0",
"description": "Caveman 是面向 Codex 的极简输出风格和压缩式协作 skill 集合。", "description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
"author": { "author": {
"name": "Julius Brussee", "name": "Julius Brussee",
"url": "https://github.com/JuliusBrussee" "url": "https://github.com/JuliusBrussee"
@@ -18,8 +18,8 @@
"skills": "./skills/", "skills": "./skills/",
"interface": { "interface": {
"displayName": "Caveman", "displayName": "Caveman",
"shortDescription": " Codex 输出更短,同时保留技术准确性。", "shortDescription": "精简 Codex 输出,并提供调查、实现、迁移、重构、评审和验证工作流。",
"longDescription": "Caveman 提供简输出风格和压缩式子 Agent 协作指引。此公共市场版本只发布 Codex plugin 内可直接使用的 skills;不包含上游一键安装器、Claude Code hooks/statusline、MCP shrink、根目录 slash commands,以及依赖 Anthropic API 或 Claude CLI 的压缩脚本 skill。", "longDescription": "Caveman 提供简输出、提交信息、代码评审、问题调查、精益实现、安全重构、迁移、补丁和验证工作流。另含可选的 caveman-compress;它需要 Python 3.10 及 Anthropic API Key 或 Claude CLI,仅在用户明确指定文件后把该文件内容发送给 Anthropic。Claude Code 专用统计 hooksstatusline、Caveman CLI、代理和 MCP shrink 不包含在此插件中。",
"developerName": "Julius Brussee", "developerName": "Julius Brussee",
"category": "Developer Tools", "category": "Developer Tools",
"capabilities": [ "capabilities": [
@@ -31,7 +31,8 @@
"termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE", "termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE",
"defaultPrompt": [ "defaultPrompt": [
"使用 Caveman 模式回答,压缩表达但保留技术准确性。", "使用 Caveman 模式回答,压缩表达但保留技术准确性。",
"使用 Cavecrew 帮我判断这个任务是否适合分派给压缩输出的子 Agent。" "使用 investigate-first 先调查这个故障的可信根因。",
"使用 surgical-patch 对这个问题做最小且经过验证的修复。"
], ],
"composerIcon": "./assets/caveman-small.svg", "composerIcon": "./assets/caveman-small.svg",
"logo": "./assets/caveman.svg", "logo": "./assets/caveman.svg",
@@ -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:**
- `<type>(<scope>): <imperative summary>``<scope>` 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.
@@ -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 `<filename>.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/`, or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` on Windows) so skill auto-loaders don't re-ingest it as a live file.
## Trigger
`/caveman-compress <filepath>` 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 <absolute_filepath>
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)
@@ -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"
@@ -0,0 +1,3 @@
from .cli import main
main()
@@ -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 <repo_root>/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()
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Caveman Compress CLI
Usage:
caveman <filepath>
"""
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 <filepath>")
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()
@@ -0,0 +1,717 @@
#!/usr/bin/env python3
"""
Caveman Memory Compression Orchestrator
Usage:
python scripts/compress.py <filepath>
"""
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
@@ -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 <file1> [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}")
@@ -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 <original> <compressed>")
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}")
@@ -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<line>: <problem>. <fix>.` — or `<file>:L<line>: ...` 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.
@@ -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.
@@ -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."
@@ -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.
@@ -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."
@@ -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.
@@ -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."
@@ -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.
@@ -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."
@@ -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.
@@ -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."
@@ -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.
@@ -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."
+34
View File
@@ -785,11 +785,45 @@ function Sync-CodexPlugin {
Copy-PathIfExists -Source $src -Destination $dst -Within $RepoRoot Copy-PathIfExists -Source $src -Destination $dst -Within $RepoRoot
} }
Copy-CodexManifestDeclaredPaths -SourceRoot $sourcePath -PluginDir $pluginDir -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-ConfiguredExcludes -Root $pluginDir -ExcludePaths $Source.excludePaths -Within $RepoRoot
Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes
Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes
Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths
Apply-LocalOverrides -PluginDir $pluginDir -Source $Source 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 Write-Provenance -PluginDir $pluginDir -Source $Source -Commit $Commit -SyncedAt $SyncedAt -SourcePath $relativeSourcePath
} }