@abarcenas/ai-workflow-template 1.1.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/instructions/learned-knowledge.instructions.md +46 -0
- package/{.github → .agents}/instructions/memory-bank.instructions.md +14 -0
- package/{.github → .agents}/instructions/task-implementation.instructions.md +8 -0
- package/.agents/skills/architecture-blueprint-generator/SKILL.md +378 -0
- package/.agents/skills/cavecrew/SKILL.md +82 -0
- package/.agents/skills/caveman/SKILL.md +74 -0
- package/.agents/skills/caveman-commit/SKILL.md +65 -0
- package/.agents/skills/caveman-compress/README.md +163 -0
- package/.agents/skills/caveman-compress/SECURITY.md +31 -0
- package/.agents/skills/caveman-compress/SKILL.md +111 -0
- package/.agents/skills/caveman-compress/scripts/__init__.py +9 -0
- package/.agents/skills/caveman-compress/scripts/__main__.py +3 -0
- package/.agents/skills/caveman-compress/scripts/benchmark.py +78 -0
- package/.agents/skills/caveman-compress/scripts/cli.py +85 -0
- package/.agents/skills/caveman-compress/scripts/compress.py +254 -0
- package/.agents/skills/caveman-compress/scripts/detect.py +121 -0
- package/.agents/skills/caveman-compress/scripts/validate.py +213 -0
- package/.agents/skills/caveman-help/SKILL.md +59 -0
- package/.agents/skills/caveman-review/SKILL.md +55 -0
- package/.agents/skills/caveman-stats/SKILL.md +10 -0
- package/.agents/skills/compress/SKILL.md +111 -0
- package/.agents/skills/compress/scripts/__init__.py +9 -0
- package/.agents/skills/compress/scripts/__main__.py +3 -0
- package/.agents/skills/compress/scripts/benchmark.py +78 -0
- package/.agents/skills/compress/scripts/cli.py +85 -0
- package/.agents/skills/compress/scripts/compress.py +254 -0
- package/.agents/skills/compress/scripts/detect.py +121 -0
- package/.agents/skills/compress/scripts/validate.py +213 -0
- package/{.github/agents/implementation-plan.agent.md → .agents/skills/create-implementation-plan/SKILL.md} +17 -52
- package/.agents/skills/create-specification/SKILL.md +127 -0
- package/AGENTS.md +149 -0
- package/README.md +197 -60
- package/opencode.mcp.example.json +54 -0
- package/package.json +13 -5
- package/scripts/mcp/playwright-mcp-launcher.js +46 -0
- package/scripts/sync.js +54 -11
- package/.github/agents/plan.agent.md +0 -135
- package/.github/agents/research-spike.agent.md +0 -204
- package/.github/prompts/copilot-instructions-blueprint-generator.prompt.md +0 -317
- /package/{.github → .agents}/instructions/agent-safety.instructions.md +0 -0
- /package/{.github → .agents}/instructions/agent-skill.instructions.md +0 -0
- /package/{.github → .agents}/instructions/agent.instructions.md +0 -0
- /package/{.github → .agents}/instructions/ai-prompt-engineering-safety.instructions.md +0 -0
- /package/{.github → .agents}/instructions/angular.instructions.md +0 -0
- /package/{.github → .agents}/instructions/context7.instructions.md +0 -0
- /package/{.github → .agents}/instructions/go.instructions.md +0 -0
- /package/{.github → .agents}/instructions/html-to-css-guide.instructions.md +0 -0
- /package/{.github → .agents}/instructions/instructions.instructions.md +0 -0
- /package/{.github → .agents}/instructions/markdown.instructions.md +0 -0
- /package/{.github → .agents}/instructions/nodejs-vitest.instructions.md +0 -0
- /package/{.github → .agents}/instructions/playwright-typescript.instructions.md +0 -0
- /package/{.github → .agents}/instructions/prompt.instructions.md +0 -0
- /package/{.github → .agents}/instructions/security-owasp.instructions.md +0 -0
- /package/{.github → .agents}/instructions/shell.instructions.md +0 -0
- /package/{.github → .agents}/instructions/typescript.instructions.md +0 -0
- /package/{.github → .agents}/prompts/documentation-writer.prompt.md +0 -0
- /package/{.github → .agents}/prompts/editconfig.prompt.md +0 -0
- /package/{.github → .agents}/prompts/playwright-explore.prompt.md +0 -0
- /package/{.github → .agents}/prompts/playwright-generate-test.prompt.md +0 -0
- /package/{.github → .agents}/skills/agent-governance/SKILL.md +0 -0
- /package/{.github → .agents}/skills/boost-prompt/SKILL.md +0 -0
- /package/{.github → .agents}/skills/chrome-devtools/SKILL.md +0 -0
- /package/{.github → .agents}/skills/context-map/SKILL.md +0 -0
- /package/{.github → .agents}/skills/conventional-commit/SKILL.md +0 -0
- /package/{.github → .agents}/skills/create-readme/SKILL.md +0 -0
- /package/{.github → .agents}/skills/editorconfig/SKILL.md +0 -0
- /package/{.github → .agents}/skills/gh-cli/SKILL.md +0 -0
- /package/{.github → .agents}/skills/git-commit/SKILL.md +0 -0
- /package/{.github → .agents}/skills/github-issues/SKILL.md +0 -0
- /package/{.github → .agents}/skills/github-issues/references/templates.md +0 -0
- /package/{.github → .agents}/skills/microsoft-docs/SKILL.md +0 -0
- /package/{.github → .agents}/skills/playwright-explore-website/SKILL.md +0 -0
- /package/{.github → .agents}/skills/playwright-generate-test/SKILL.md +0 -0
- /package/{.github → .agents}/skills/polygot-test-agent/SKILL.md +0 -0
- /package/{.github → .agents}/skills/polygot-test-agent/unit-test-genearation.prompt.md +0 -0
- /package/{.github → .agents}/skills/prompt-builder/SKILL.md +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Caveman Memory Compression Orchestrator
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python scripts/compress.py <filepath>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
OUTER_FENCE_REGEX = re.compile(
|
|
16
|
+
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Filenames and paths that almost certainly hold secrets or PII. Compressing
|
|
20
|
+
# them ships raw bytes to the Anthropic API — a third-party data boundary that
|
|
21
|
+
# developers on sensitive codebases cannot cross. detect.py already skips .env
|
|
22
|
+
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
|
|
23
|
+
# slip through the natural-language filter. This is a hard refuse before read.
|
|
24
|
+
SENSITIVE_BASENAME_REGEX = re.compile(
|
|
25
|
+
r"(?ix)^("
|
|
26
|
+
r"\.env(\..+)?"
|
|
27
|
+
r"|\.netrc"
|
|
28
|
+
r"|credentials(\..+)?"
|
|
29
|
+
r"|secrets?(\..+)?"
|
|
30
|
+
r"|passwords?(\..+)?"
|
|
31
|
+
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
|
|
32
|
+
r"|authorized_keys"
|
|
33
|
+
r"|known_hosts"
|
|
34
|
+
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
|
|
35
|
+
r")$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
|
|
39
|
+
|
|
40
|
+
SENSITIVE_NAME_TOKENS = (
|
|
41
|
+
"secret", "credential", "password", "passwd",
|
|
42
|
+
"apikey", "accesskey", "token", "privatekey",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_sensitive_path(filepath: Path) -> bool:
|
|
47
|
+
"""Heuristic denylist for files that must never be shipped to a third-party API."""
|
|
48
|
+
name = filepath.name
|
|
49
|
+
if SENSITIVE_BASENAME_REGEX.match(name):
|
|
50
|
+
return True
|
|
51
|
+
lowered_parts = {p.lower() for p in filepath.parts}
|
|
52
|
+
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
|
|
53
|
+
return True
|
|
54
|
+
# Normalize separators so "api-key" and "api_key" both match "apikey".
|
|
55
|
+
lower = re.sub(r"[_\-\s.]", "", name.lower())
|
|
56
|
+
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def strip_llm_wrapper(text: str) -> str:
|
|
60
|
+
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
|
|
61
|
+
m = OUTER_FENCE_REGEX.match(text)
|
|
62
|
+
if m:
|
|
63
|
+
return m.group(2)
|
|
64
|
+
return text
|
|
65
|
+
|
|
66
|
+
from .detect import should_compress
|
|
67
|
+
from .validate import validate
|
|
68
|
+
|
|
69
|
+
MAX_RETRIES = 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------- Claude Calls ----------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def call_claude(prompt: str) -> str:
|
|
76
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
77
|
+
if api_key:
|
|
78
|
+
try:
|
|
79
|
+
import anthropic
|
|
80
|
+
|
|
81
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
82
|
+
msg = client.messages.create(
|
|
83
|
+
model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
|
|
84
|
+
max_tokens=8192,
|
|
85
|
+
messages=[{"role": "user", "content": prompt}],
|
|
86
|
+
)
|
|
87
|
+
return strip_llm_wrapper(msg.content[0].text.strip())
|
|
88
|
+
except ImportError:
|
|
89
|
+
pass # anthropic not installed, fall back to CLI
|
|
90
|
+
# Fallback: use claude CLI (handles desktop auth)
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["claude", "--print"],
|
|
94
|
+
input=prompt,
|
|
95
|
+
text=True,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
check=True,
|
|
98
|
+
)
|
|
99
|
+
return strip_llm_wrapper(result.stdout.strip())
|
|
100
|
+
except subprocess.CalledProcessError as e:
|
|
101
|
+
raise RuntimeError(f"Claude call failed:\n{e.stderr}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_compress_prompt(original: str) -> str:
|
|
105
|
+
return f"""
|
|
106
|
+
Compress this markdown into caveman format.
|
|
107
|
+
|
|
108
|
+
STRICT RULES:
|
|
109
|
+
- Do NOT modify anything inside ``` code blocks
|
|
110
|
+
- Do NOT modify anything inside inline backticks
|
|
111
|
+
- Preserve ALL URLs exactly
|
|
112
|
+
- Preserve ALL headings exactly
|
|
113
|
+
- Preserve file paths and commands
|
|
114
|
+
- 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.
|
|
115
|
+
|
|
116
|
+
Only compress natural language.
|
|
117
|
+
|
|
118
|
+
TEXT:
|
|
119
|
+
{original}
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
|
|
124
|
+
errors_str = "\n".join(f"- {e}" for e in errors)
|
|
125
|
+
return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
|
|
126
|
+
|
|
127
|
+
CRITICAL RULES:
|
|
128
|
+
- DO NOT recompress or rephrase the file
|
|
129
|
+
- ONLY fix the listed errors — leave everything else exactly as-is
|
|
130
|
+
- The ORIGINAL is provided as reference only (to restore missing content)
|
|
131
|
+
- Preserve caveman style in all untouched sections
|
|
132
|
+
|
|
133
|
+
ERRORS TO FIX:
|
|
134
|
+
{errors_str}
|
|
135
|
+
|
|
136
|
+
HOW TO FIX:
|
|
137
|
+
- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
|
|
138
|
+
- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
|
|
139
|
+
- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
|
|
140
|
+
- Do not touch any section not mentioned in the errors
|
|
141
|
+
|
|
142
|
+
ORIGINAL (reference only):
|
|
143
|
+
{original}
|
|
144
|
+
|
|
145
|
+
COMPRESSED (fix this):
|
|
146
|
+
{compressed}
|
|
147
|
+
|
|
148
|
+
Return ONLY the fixed compressed file. No explanation.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------- Core Logic ----------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def compress_file(filepath: Path) -> bool:
|
|
156
|
+
# Resolve and validate path
|
|
157
|
+
filepath = filepath.resolve()
|
|
158
|
+
MAX_FILE_SIZE = 500_000 # 500KB
|
|
159
|
+
if not filepath.exists():
|
|
160
|
+
raise FileNotFoundError(f"File not found: {filepath}")
|
|
161
|
+
if filepath.stat().st_size > MAX_FILE_SIZE:
|
|
162
|
+
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
|
|
163
|
+
|
|
164
|
+
# Refuse files that look like they contain secrets or PII. Compressing ships
|
|
165
|
+
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
|
|
166
|
+
# loudly rather than silently exfiltrate credentials or keys. Override is
|
|
167
|
+
# intentional: the user must rename the file if the heuristic is wrong.
|
|
168
|
+
if is_sensitive_path(filepath):
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Refusing to compress {filepath}: filename looks sensitive "
|
|
171
|
+
"(credentials, keys, secrets, or known private paths). "
|
|
172
|
+
"Compression sends file contents to the Anthropic API. "
|
|
173
|
+
"Rename the file if this is a false positive."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
print(f"Processing: {filepath}")
|
|
177
|
+
|
|
178
|
+
if not should_compress(filepath):
|
|
179
|
+
print("Skipping (not natural language)")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
original_text = filepath.read_text(errors="ignore")
|
|
183
|
+
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
|
184
|
+
|
|
185
|
+
if not original_text.strip():
|
|
186
|
+
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Check if backup already exists to prevent accidental overwriting
|
|
190
|
+
if backup_path.exists():
|
|
191
|
+
print(f"⚠️ Backup file already exists: {backup_path}")
|
|
192
|
+
print("The original backup may contain important content.")
|
|
193
|
+
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
# Step 1: Compress
|
|
197
|
+
print("Compressing with Claude...")
|
|
198
|
+
compressed = call_claude(build_compress_prompt(original_text))
|
|
199
|
+
|
|
200
|
+
if compressed is None or not compressed.strip():
|
|
201
|
+
print("❌ Compression aborted: Claude returned an empty response.")
|
|
202
|
+
print(" Original file is untouched (no backup created).")
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
if compressed.strip() == original_text.strip():
|
|
206
|
+
print("❌ Compression aborted: output is identical to input.")
|
|
207
|
+
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
|
208
|
+
print(" already in caveman form. Original file is untouched (no backup created).")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
# Save original as backup, then verify the backup readback before
|
|
212
|
+
# touching the input file. If the filesystem dropped bytes (encoding,
|
|
213
|
+
# antivirus, disk full), unlink the bad backup and abort instead of
|
|
214
|
+
# leaving the user with a corrupt backup + compressed primary.
|
|
215
|
+
backup_path.write_text(original_text)
|
|
216
|
+
backup_readback = backup_path.read_text(errors="ignore")
|
|
217
|
+
if backup_readback != original_text:
|
|
218
|
+
print(f"❌ Backup write verification failed: {backup_path}")
|
|
219
|
+
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
|
220
|
+
try:
|
|
221
|
+
backup_path.unlink()
|
|
222
|
+
except OSError:
|
|
223
|
+
pass
|
|
224
|
+
return False
|
|
225
|
+
filepath.write_text(compressed)
|
|
226
|
+
|
|
227
|
+
# Step 2: Validate + Retry
|
|
228
|
+
for attempt in range(MAX_RETRIES):
|
|
229
|
+
print(f"\nValidation attempt {attempt + 1}")
|
|
230
|
+
|
|
231
|
+
result = validate(backup_path, filepath)
|
|
232
|
+
|
|
233
|
+
if result.is_valid:
|
|
234
|
+
print("Validation passed")
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
print("❌ Validation failed:")
|
|
238
|
+
for err in result.errors:
|
|
239
|
+
print(f" - {err}")
|
|
240
|
+
|
|
241
|
+
if attempt == MAX_RETRIES - 1:
|
|
242
|
+
# Restore original on failure
|
|
243
|
+
filepath.write_text(original_text)
|
|
244
|
+
backup_path.unlink(missing_ok=True)
|
|
245
|
+
print("❌ Failed after retries — original restored")
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
print("Fixing with Claude...")
|
|
249
|
+
compressed = call_claude(
|
|
250
|
+
build_fix_prompt(original_text, compressed, result.errors)
|
|
251
|
+
)
|
|
252
|
+
filepath.write_text(compressed)
|
|
253
|
+
|
|
254
|
+
return True
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detect whether a file is natural language (compressible) or code/config (skip)."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Extensions that are natural language and compressible
|
|
9
|
+
COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"}
|
|
10
|
+
|
|
11
|
+
# Extensions that are code/config and should be skipped
|
|
12
|
+
SKIP_EXTENSIONS = {
|
|
13
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
|
|
14
|
+
".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml",
|
|
15
|
+
".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c",
|
|
16
|
+
".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua",
|
|
17
|
+
".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Patterns that indicate a line is code
|
|
21
|
+
CODE_PATTERNS = [
|
|
22
|
+
re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
|
|
23
|
+
re.compile(r"^\s*(def |class |function |async function |export )"),
|
|
24
|
+
re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"),
|
|
25
|
+
re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets
|
|
26
|
+
re.compile(r"^\s*@\w+"), # decorators/annotations
|
|
27
|
+
re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value
|
|
28
|
+
re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_code_line(line: str) -> bool:
|
|
33
|
+
"""Check if a line looks like code."""
|
|
34
|
+
return any(p.match(line) for p in CODE_PATTERNS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_json_content(text: str) -> bool:
|
|
38
|
+
"""Check if content is valid JSON."""
|
|
39
|
+
try:
|
|
40
|
+
json.loads(text)
|
|
41
|
+
return True
|
|
42
|
+
except (json.JSONDecodeError, ValueError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_yaml_content(lines: list[str]) -> bool:
|
|
47
|
+
"""Heuristic: check if content looks like YAML."""
|
|
48
|
+
yaml_indicators = 0
|
|
49
|
+
for line in lines[:30]:
|
|
50
|
+
stripped = line.strip()
|
|
51
|
+
if stripped.startswith("---"):
|
|
52
|
+
yaml_indicators += 1
|
|
53
|
+
elif re.match(r"^\w[\w\s]*:\s", stripped):
|
|
54
|
+
yaml_indicators += 1
|
|
55
|
+
elif stripped.startswith("- ") and ":" in stripped:
|
|
56
|
+
yaml_indicators += 1
|
|
57
|
+
# If most non-empty lines look like YAML
|
|
58
|
+
non_empty = sum(1 for l in lines[:30] if l.strip())
|
|
59
|
+
return non_empty > 0 and yaml_indicators / non_empty > 0.6
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect_file_type(filepath: Path) -> str:
|
|
63
|
+
"""Classify a file as 'natural_language', 'code', 'config', or 'unknown'.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
One of: 'natural_language', 'code', 'config', 'unknown'
|
|
67
|
+
"""
|
|
68
|
+
ext = filepath.suffix.lower()
|
|
69
|
+
|
|
70
|
+
# Extension-based classification
|
|
71
|
+
if ext in COMPRESSIBLE_EXTENSIONS:
|
|
72
|
+
return "natural_language"
|
|
73
|
+
if ext in SKIP_EXTENSIONS:
|
|
74
|
+
return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config"
|
|
75
|
+
|
|
76
|
+
# Extensionless files (like CLAUDE.md, TODO) — check content
|
|
77
|
+
if not ext:
|
|
78
|
+
try:
|
|
79
|
+
text = filepath.read_text(errors="ignore")
|
|
80
|
+
except (OSError, PermissionError):
|
|
81
|
+
return "unknown"
|
|
82
|
+
|
|
83
|
+
lines = text.splitlines()[:50]
|
|
84
|
+
|
|
85
|
+
if _is_json_content(text[:10000]):
|
|
86
|
+
return "config"
|
|
87
|
+
if _is_yaml_content(lines):
|
|
88
|
+
return "config"
|
|
89
|
+
|
|
90
|
+
code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l))
|
|
91
|
+
non_empty = sum(1 for l in lines if l.strip())
|
|
92
|
+
if non_empty > 0 and code_lines / non_empty > 0.4:
|
|
93
|
+
return "code"
|
|
94
|
+
|
|
95
|
+
return "natural_language"
|
|
96
|
+
|
|
97
|
+
return "unknown"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def should_compress(filepath: Path) -> bool:
|
|
101
|
+
"""Return True if the file is natural language and should be compressed."""
|
|
102
|
+
if not filepath.is_file():
|
|
103
|
+
return False
|
|
104
|
+
# Skip backup files
|
|
105
|
+
if filepath.name.endswith(".original.md"):
|
|
106
|
+
return False
|
|
107
|
+
return detect_file_type(filepath) == "natural_language"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
import sys
|
|
112
|
+
|
|
113
|
+
if len(sys.argv) < 2:
|
|
114
|
+
print("Usage: python detect.py <file1> [file2] ...")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
for path_str in sys.argv[1:]:
|
|
118
|
+
p = Path(path_str).resolve()
|
|
119
|
+
file_type = detect_file_type(p)
|
|
120
|
+
compress = should_compress(p)
|
|
121
|
+
print(f" {p.name:30s} type={file_type:20s} compress={compress}")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
|
7
|
+
FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$")
|
|
8
|
+
HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE)
|
|
9
|
+
BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
|
|
10
|
+
|
|
11
|
+
# crude but effective path detection
|
|
12
|
+
# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match
|
|
13
|
+
PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationResult:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.is_valid = True
|
|
19
|
+
self.errors = []
|
|
20
|
+
self.warnings = []
|
|
21
|
+
|
|
22
|
+
def add_error(self, msg):
|
|
23
|
+
self.is_valid = False
|
|
24
|
+
self.errors.append(msg)
|
|
25
|
+
|
|
26
|
+
def add_warning(self, msg):
|
|
27
|
+
self.warnings.append(msg)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_file(path: Path) -> str:
|
|
31
|
+
return path.read_text(errors="ignore")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------- Extractors ----------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_headings(text):
|
|
38
|
+
return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_code_blocks(text):
|
|
42
|
+
"""Line-based fenced code block extractor.
|
|
43
|
+
|
|
44
|
+
Handles ``` and ~~~ fences with variable length (CommonMark: closing
|
|
45
|
+
fence must use same char and be at least as long as opening). Supports
|
|
46
|
+
nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
|
|
47
|
+
content).
|
|
48
|
+
"""
|
|
49
|
+
blocks = []
|
|
50
|
+
lines = text.split("\n")
|
|
51
|
+
i = 0
|
|
52
|
+
n = len(lines)
|
|
53
|
+
while i < n:
|
|
54
|
+
m = FENCE_OPEN_REGEX.match(lines[i])
|
|
55
|
+
if not m:
|
|
56
|
+
i += 1
|
|
57
|
+
continue
|
|
58
|
+
fence_char = m.group(2)[0]
|
|
59
|
+
fence_len = len(m.group(2))
|
|
60
|
+
open_line = lines[i]
|
|
61
|
+
block_lines = [open_line]
|
|
62
|
+
i += 1
|
|
63
|
+
closed = False
|
|
64
|
+
while i < n:
|
|
65
|
+
close_m = FENCE_OPEN_REGEX.match(lines[i])
|
|
66
|
+
if (
|
|
67
|
+
close_m
|
|
68
|
+
and close_m.group(2)[0] == fence_char
|
|
69
|
+
and len(close_m.group(2)) >= fence_len
|
|
70
|
+
and close_m.group(3).strip() == ""
|
|
71
|
+
):
|
|
72
|
+
block_lines.append(lines[i])
|
|
73
|
+
closed = True
|
|
74
|
+
i += 1
|
|
75
|
+
break
|
|
76
|
+
block_lines.append(lines[i])
|
|
77
|
+
i += 1
|
|
78
|
+
if closed:
|
|
79
|
+
blocks.append("\n".join(block_lines))
|
|
80
|
+
# Unclosed fences are silently skipped — they indicate malformed markdown
|
|
81
|
+
# and including them would cause false-positive validation failures.
|
|
82
|
+
return blocks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_urls(text):
|
|
86
|
+
return set(URL_REGEX.findall(text))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_paths(text):
|
|
90
|
+
return set(PATH_REGEX.findall(text))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def count_bullets(text):
|
|
94
|
+
return len(BULLET_REGEX.findall(text))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_inline_codes(text):
|
|
98
|
+
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
|
99
|
+
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
|
100
|
+
return re.findall(r"`([^`]+)`", text_without_fences)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------- Validators ----------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_headings(orig, comp, result):
|
|
107
|
+
h1 = extract_headings(orig)
|
|
108
|
+
h2 = extract_headings(comp)
|
|
109
|
+
|
|
110
|
+
if len(h1) != len(h2):
|
|
111
|
+
result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}")
|
|
112
|
+
|
|
113
|
+
if h1 != h2:
|
|
114
|
+
result.add_warning("Heading text/order changed")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_code_blocks(orig, comp, result):
|
|
118
|
+
c1 = extract_code_blocks(orig)
|
|
119
|
+
c2 = extract_code_blocks(comp)
|
|
120
|
+
|
|
121
|
+
if c1 != c2:
|
|
122
|
+
result.add_error("Code blocks not preserved exactly")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_urls(orig, comp, result):
|
|
126
|
+
u1 = extract_urls(orig)
|
|
127
|
+
u2 = extract_urls(comp)
|
|
128
|
+
|
|
129
|
+
if u1 != u2:
|
|
130
|
+
result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_paths(orig, comp, result):
|
|
134
|
+
p1 = extract_paths(orig)
|
|
135
|
+
p2 = extract_paths(comp)
|
|
136
|
+
|
|
137
|
+
if p1 != p2:
|
|
138
|
+
result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_bullets(orig, comp, result):
|
|
142
|
+
b1 = count_bullets(orig)
|
|
143
|
+
b2 = count_bullets(comp)
|
|
144
|
+
|
|
145
|
+
if b1 == 0:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
diff = abs(b1 - b2) / b1
|
|
149
|
+
|
|
150
|
+
if diff > 0.15:
|
|
151
|
+
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_inline_codes(orig, comp, result):
|
|
155
|
+
c1 = Counter(extract_inline_codes(orig))
|
|
156
|
+
c2 = Counter(extract_inline_codes(comp))
|
|
157
|
+
|
|
158
|
+
if c1 != c2:
|
|
159
|
+
lost = set(c1.keys()) - set(c2.keys())
|
|
160
|
+
added = set(c2.keys()) - set(c1.keys())
|
|
161
|
+
for code, count in c1.items():
|
|
162
|
+
if code in c2 and c2[code] < count:
|
|
163
|
+
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
|
|
164
|
+
if lost:
|
|
165
|
+
result.add_error(f"Inline code lost: {lost}")
|
|
166
|
+
if added:
|
|
167
|
+
result.add_warning(f"Inline code added: {added}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------- Main ----------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
|
174
|
+
result = ValidationResult()
|
|
175
|
+
|
|
176
|
+
orig = read_file(original_path)
|
|
177
|
+
comp = read_file(compressed_path)
|
|
178
|
+
|
|
179
|
+
validate_headings(orig, comp, result)
|
|
180
|
+
validate_code_blocks(orig, comp, result)
|
|
181
|
+
validate_urls(orig, comp, result)
|
|
182
|
+
validate_paths(orig, comp, result)
|
|
183
|
+
validate_bullets(orig, comp, result)
|
|
184
|
+
validate_inline_codes(orig, comp, result)
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------- CLI ----------
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
import sys
|
|
193
|
+
|
|
194
|
+
if len(sys.argv) != 3:
|
|
195
|
+
print("Usage: python validate.py <original> <compressed>")
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
orig = Path(sys.argv[1]).resolve()
|
|
199
|
+
comp = Path(sys.argv[2]).resolve()
|
|
200
|
+
|
|
201
|
+
res = validate(orig, comp)
|
|
202
|
+
|
|
203
|
+
print(f"\nValid: {res.is_valid}")
|
|
204
|
+
|
|
205
|
+
if res.errors:
|
|
206
|
+
print("\nErrors:")
|
|
207
|
+
for e in res.errors:
|
|
208
|
+
print(f" - {e}")
|
|
209
|
+
|
|
210
|
+
if res.warnings:
|
|
211
|
+
print("\nWarnings:")
|
|
212
|
+
for w in res.warnings:
|
|
213
|
+
print(f" - {w}")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caveman-help
|
|
3
|
+
description: >
|
|
4
|
+
Quick-reference card for all caveman modes, skills, and commands.
|
|
5
|
+
One-shot display, not a persistent mode. Trigger: /caveman-help,
|
|
6
|
+
"caveman help", "what caveman commands", "how do I use caveman".
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Caveman Help
|
|
10
|
+
|
|
11
|
+
Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style.
|
|
12
|
+
|
|
13
|
+
## Modes
|
|
14
|
+
|
|
15
|
+
| Mode | Trigger | What change |
|
|
16
|
+
|------|---------|-------------|
|
|
17
|
+
| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. |
|
|
18
|
+
| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. |
|
|
19
|
+
| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. |
|
|
20
|
+
| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. |
|
|
21
|
+
| **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. |
|
|
22
|
+
| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. |
|
|
23
|
+
|
|
24
|
+
Mode stick until changed or session end.
|
|
25
|
+
|
|
26
|
+
## Skills
|
|
27
|
+
|
|
28
|
+
| Skill | Trigger | What it do |
|
|
29
|
+
|-------|---------|-----------|
|
|
30
|
+
| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. |
|
|
31
|
+
| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` |
|
|
32
|
+
| **caveman-compress** | `/caveman:compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
|
|
33
|
+
| **caveman-help** | `/caveman-help` | This card. |
|
|
34
|
+
|
|
35
|
+
## Deactivate
|
|
36
|
+
|
|
37
|
+
Say "stop caveman" or "normal mode". Resume anytime with `/caveman`.
|
|
38
|
+
|
|
39
|
+
## Configure Default Mode
|
|
40
|
+
|
|
41
|
+
Default mode = `full`. Change it:
|
|
42
|
+
|
|
43
|
+
**Environment variable** (highest priority):
|
|
44
|
+
```bash
|
|
45
|
+
export CAVEMAN_DEFAULT_MODE=ultra
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Config file** (`~/.config/caveman/config.json`):
|
|
49
|
+
```json
|
|
50
|
+
{ "defaultMode": "lite" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`.
|
|
54
|
+
|
|
55
|
+
Resolution: env var > config file > `full`.
|
|
56
|
+
|
|
57
|
+
## More
|
|
58
|
+
|
|
59
|
+
Full docs: https://github.com/JuliusBrussee/caveman
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caveman-review
|
|
3
|
+
description: >
|
|
4
|
+
Ultra-compressed code review comments. Cuts noise from PR feedback while preserving
|
|
5
|
+
the actionable signal. Each comment is one line: location, problem, fix. Use when user
|
|
6
|
+
says "review this PR", "code review", "review the diff", "/review", or invokes
|
|
7
|
+
/caveman-review. Auto-triggers when reviewing pull requests.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing.
|
|
11
|
+
|
|
12
|
+
## Rules
|
|
13
|
+
|
|
14
|
+
**Format:** `L<line>: <problem>. <fix>.` — or `<file>:L<line>: ...` when reviewing multi-file diffs.
|
|
15
|
+
|
|
16
|
+
**Severity prefix (optional, when mixed):**
|
|
17
|
+
- `🔴 bug:` — broken behavior, will cause incident
|
|
18
|
+
- `🟡 risk:` — works but fragile (race, missing null check, swallowed error)
|
|
19
|
+
- `🔵 nit:` — style, naming, micro-optim. Author can ignore
|
|
20
|
+
- `❓ q:` — genuine question, not a suggestion
|
|
21
|
+
|
|
22
|
+
**Drop:**
|
|
23
|
+
- "I noticed that...", "It seems like...", "You might want to consider..."
|
|
24
|
+
- "This is just a suggestion but..." — use `nit:` instead
|
|
25
|
+
- "Great work!", "Looks good overall but..." — say it once at the top, not per comment
|
|
26
|
+
- Restating what the line does — the reviewer can read the diff
|
|
27
|
+
- Hedging ("perhaps", "maybe", "I think") — if unsure use `q:`
|
|
28
|
+
|
|
29
|
+
**Keep:**
|
|
30
|
+
- Exact line numbers
|
|
31
|
+
- Exact symbol/function/variable names in backticks
|
|
32
|
+
- Concrete fix, not "consider refactoring this"
|
|
33
|
+
- The *why* if the fix isn't obvious from the problem statement
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
❌ "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."
|
|
38
|
+
|
|
39
|
+
✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.`
|
|
40
|
+
|
|
41
|
+
❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability."
|
|
42
|
+
|
|
43
|
+
✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.`
|
|
44
|
+
|
|
45
|
+
❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case."
|
|
46
|
+
|
|
47
|
+
✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).`
|
|
48
|
+
|
|
49
|
+
## Auto-Clarity
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
## Boundaries
|
|
54
|
+
|
|
55
|
+
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.
|