Compare commits

...
14 Commits
Author SHA1 Message Date
Andrew QuandGitHub 3098342331 Update README.md with Skills.sh badge (#338) 2026-08-10 19:59:13 +02:00
Julius Brussee 11ddc0c981 docs: simplify install section
Make the README install flow easier to scan by surfacing one-agent install paths directly and pointing users to `INSTALL.md` for the full matrix. Also ignore the local `tmp-starcharts/` scratch directory so generated chart work stays out of git.
2026-08-08 13:04:58 +02:00
Julius BrusseeandClaude Opus 5 14d4f2e21a docs: add Trendshift badge to README header
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012SSMrNKRooMGmJ1yNFpLMy
2026-08-08 00:48:02 +02:00
Julius BrusseeandGitHub ec83e5bace Update README for clarity on coding runs 2026-08-04 15:44:52 +02:00
Julius Brussee 7066cc8154 chore: use local star history image in README 2026-08-03 15:34:49 +02:00
github-actions[bot] fcf7663366 chore: sync SKILL.md copies [skip ci] 2026-08-03 10:57:48 +00:00
Julius BrusseeandClaude Opus 5 e07431f127 release: pin install ref to v1.10.0
The sweep adds src/hooks/caveman-parse.js and regenerates
checksums.sha256 to include it. The detached install path fetches
hooks from RAW_BASE = raw.githubusercontent.com/<repo>/<PINNED_REF>,
and the integrity gate refuses any hook that doesn't match the pinned
release. Left at v1.9.1 that ref has no caveman-parse.js, so
curl | bash 404s and fails integrity for new users.

Bump must land inside the tagged commit so v1.10.0 contains both the
new hooks and a PINNED_REF pointing at itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012SSMrNKRooMGmJ1yNFpLMy
2026-08-03 12:57:36 +02:00
Julius Brussee 52bbbcdd86 docs: clarify token savings by workload
Update README to separate the 65% chat-style prose benchmark from full agentic coding runs, adding JetBrains’ independent 86-task result (8.5% output-token savings). Adds context on why both numbers are valid, explains workload-dependent ceilings, and aligns Caveman 2 positioning around measuring real savings on each team’s own traffic.
2026-08-03 12:51:36 +02:00
Julius BrusseeandClaude Fable 5 6566ec23d3 ci: run the full test suite on every push and PR
Tests existed but nothing ran them in CI. Node 18/20/22 matrix runs the
installer suite plus every standalone tests/test_*.js runner; a python job
runs unittest discover (compress tests mock the Claude call, no network).
Node is set up in the python job too — several python tests shell out to
node for hook checks. Inspired by PR #695, minus the scanner extras.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:01:27 +02:00
Julius BrusseeandClaude Fable 5 ed1fbb7f4c fix(install): rename bin/ to cli/, harden Windows quoting, clean uninstall
Marketplace fix (#712, #705): Claude Desktop rejects plugins containing a
top-level bin/ directory, and .claude-plugin/marketplace.json packages the
repo root, so the installer directory is now cli/. Every reference updated
(package.json bin entry + files, shims, docs, tests, caveman-init require
path). Supersedes PR #726.

Security (PR #717 verified): quoteWinArg only quoted on whitespace/quotes,
leaving cmd.exe metacharacters (& | ^ < > % parens) unescaped on the
shell:true Windows spawn path. Attacker-influenced arguments (--with-init
cwd, --with-mcp-shrink value) could chain commands. Trigger regex now
covers the metacharacter set; quoting logic split into a platform-
independent, unit-tested helper.

Also:
- uninstall removes .caveman-active.prev, .caveman-mode-log.jsonl,
  .caveman-statusline-suffix, .caveman-nudge-shown; keeps
  .caveman-history.jsonl with a printed note; dry-run now says
  'would remove' instead of lying (#635, supersedes PRs #693 #636)
- Array.isArray guard in rewriteLegacyManagedHookCommands — malformed
  hook event no longer crashes the installer mid-run (supersedes PR #646)
- gemini extensions install --consent: the security prompt hung every
  piped/non-interactive install forever (#676, part of PR #664)
- OpenClaw skill stamps the real PINNED_REF version instead of hardcoded
  1.0.0; new --no-always flag for load-on-demand installs (supersedes
  PR #720)
- shims scope NPM_CONFIG_ALLOW_GIT=all to the npx call — npm >=12
  defaults allow-git to none and EALLOWGITs github: installs (#698)
- .codex/config.toml ships hooks + codex_hooks keys so auto-activation
  works on both sides of the codex-cli rename (#617)
- caveman-help card shows the Windows config path (%APPDATA%) (#723)
- caveman-parse.js added to HOOK_FILES, opencode payload (.cjs), and the
  regenerated checksums.sha256; manifest now matches shipped hook
  contents — release must bump PINNED_REF to a tag containing these files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:01:27 +02:00
Julius BrusseeandClaude Fable 5 dcd51f16fe fix(compress): stop Windows data loss — UTF-8 + atomic writes (#652, #655, #686)
Every read/write resolved to the locale codec (cp1252/cp949 on Windows):
non-ASCII files were silently mojibake'd, and because Path.write_text
truncates before encoding, a UnicodeEncodeError left the target at 0 bytes.
The backup readback check couldn't catch it — it read back with the same
wrong codec.

- encoding=utf-8 pinned on every I/O call site (compress, validate,
  detect, benchmark); validate now decodes strict — it's the fidelity gate
- write_text_atomic: encode first, temp file in same dir, fsync, preserve
  permissions, os.replace; temp unlinked on any failure
- fix-retry pass gains the same empty-output guard as the first pass
- fix-retry preamble leak (#588): output must start at the original's
  structural anchor (frontmatter/heading) or the attempt is rejected
- primary-write failure now prints the backup path — users hitting the
  crash had no idea a backup existed
- extract_inline_codes: strip fences via the CommonMark-aware extractor;
  the old column-0 regex leaked indented fences into inline-code pairing,
  causing false validation failures (extracted from PR #619's diagnosis)
- SKILL.md/README/SECURITY corrected: backups live in the out-of-tree data
  dir (#420), not beside the source file

Supersedes PRs #683 #678 #626 #534 and the fence fix from #619 with a
local implementation. 58 python tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:59 +02:00
Julius BrusseeandClaude Fable 5 d833f4adab feat(stats): surface net savings, not just gross (#145, #677)
/caveman-stats only reported gross output savings, hiding the regime where
the ~1,250-token/turn rule injection costs more than compression saves —
the exact case docs/HONEST-NUMBERS.md warns about.

- Est. rule overhead: per-turn injected-rule input cost x turns
  (CAVEMAN_RULE_OVERHEAD_TOKENS overrides the default 1250)
- Est. net: saved minus overhead; when negative, says plainly that caveman
  cost more than it saved for this workload
- shown only for attributed uniform spans (follows #601 attribution);
  history rows without turn counts are excluded from net, never guessed
- statusline suffix stays gross savings, unchanged semantics

Reimplements the idea from PR #718 locally. 46 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:59 +02:00
Julius BrusseeandClaude Fable 5 7693a046ee fix(hooks): shared parser, envelope unwrap, resume-safe SessionStart
- extract mode parsing into src/hooks/caveman-parse.js, consumed by both
  the Claude tracker and the opencode plugin — fixes the three #602 drifts
  (brevity triggers missing, bogus level overwrote flag with default,
  independent modes unreachable via expanded templates)
- unwrap Claude Code's <command-name>/<command-args> slash envelope: real
  slash-UI /caveman <level> and /caveman off were silent no-ops (#537);
  foreign envelopes skip natural-language detection entirely
- SessionStart branches on payload source: startup resets to configured
  default, resume/clear/compact preserve a valid existing flag (#691)
- scheduled-task prompts (<scheduled-task marker) skip flag mutation and
  reinforcement so unattended runs aren't hijacked
- per-turn reinforcement honors repo-local defaultMode off via
  getDefaultMode(cwd) gate — read-only, never deletes the shared flag
  (#634; rejects #532's cross-session flag deletion)
- reinforcement anchor shrunk ~57%, opencode line kept identical (#660)
- statusline setup nudge shown once, gated by .caveman-nudge-shown (#661)
- /caveman-stats delivered via hookSpecificOutput.additionalContext so the
  macOS desktop app renders it (#618)
- safeWriteFlag: retry rename on Windows sharing violations, always unlink
  temp in finally — no more .caveman-active.<pid>.<ts> litter (#511 #578)
- statusline.sh exits 0 on empty suffix file — non-zero exit was hiding
  the whole status bar (#711)
- cavecrew-model-overrides resolves plugin root across layouts; env model
  overrides were a silent no-op (#645)
- opencode dev-tree loader: base require on the loaded file so
  caveman-parse's relative require resolves in both layouts

Supersedes PRs #623 #674 #700 #691 #634 #660 #661 #692 #632 #622 #657
#578 #511 #645 #590 #498 #501 with local implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:43 +02:00
Julius BrusseeandClaude Fable 5 710173f965 fix(skill): negation safety, tool-call silence, language-drift hardening
Verified against filed reports and applied minimal rewordings:
- never-drop-negations rule — dropping not/never inverts instructions (#670)
- tool-call silence defined: no preamble/progress notes around calls (#713, #714)
- language rule de-named: removes Portuguese/Spanish attractor tokens that
  drove drift under compaction; every emitted line in session language
  (#665, #654, #701, #539)
- particles/postpositions exempt from article-dropping — Korean/Japanese
  case markers are grammar, not filler (#680)
- classical chars scoped to wenyan modes only; Auto-Clarity example marked
  format-only so warnings render in session language (#679, #680)
- switch line covers all six levels + off (#549, #670)
- persisted-text boundary: docs/issues/PR text/memory files write normal
  (#483, #562, #582, #670)
- wenyan-full row: character reduction labeled chars-not-tokens

Supersedes PRs #715 #702 #684 #685 #667 #654 #658 #549 #670 #483 #562 #582
with local implementations. Eval snapshots need regeneration (API-gated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:27 +02:00
63 changed files with 2029 additions and 403 deletions
+3
View File
@@ -1,2 +1,5 @@
[features]
# Both keys: codex-cli renamed codex_hooks → hooks; old versions (<=0.120.0)
# silently ignore unknown keys, so shipping both activates on either (#617).
hooks = true
codex_hooks = true
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
node:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Installer test suite
run: npm test
- name: Standalone hook/tool tests
run: |
for f in tests/test_*.js; do
echo "== $f"
node "$f"
done
python:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# Several python tests shell out to node for hook checks — don't rely
# on the runner image happening to ship it.
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Python test suite
run: python -m unittest discover -s tests -v
+3
View File
@@ -12,3 +12,6 @@ context/refs/research-brief-caveman-code-efficiency.md
# Build artifacts
dist/*
!dist/caveman.skill
# Local star-history chart scratch (not part of the product)
tmp-starcharts/
+16 -16
View File
@@ -35,9 +35,9 @@ caveman/
├── CLAUDE.md # This file (maintainer instructions)
├── AGENTS.md / GEMINI.md # Autodiscovery files (must stay at root)
├── install.sh / install.ps1 # 30-line shims → bin/install.js
├── install.sh / install.ps1 # 30-line shims → cli/install.js
├── bin/ # Unified installer
├── cli/ # Unified installer
│ ├── install.js # Single source for all 30+ agents (PROVIDERS array)
│ └── lib/settings.js # JSONC-tolerant settings.json reader/writer
@@ -84,8 +84,8 @@ caveman/
|------|-----------------|
| `skills/caveman/SKILL.md` | Caveman behavior: intensity levels, rules, wenyan mode, auto-clarity, persistence. Only file to edit for behavior changes. |
| `src/rules/caveman-activate.md` | Always-on auto-activation rule body. Consumed by `src/tools/caveman-init.js` when a user runs `npx caveman --with-init` (per-repo IDE rule files). Edit here, not in any per-agent rule copy. |
| `src/rules/caveman-openclaw-bootstrap.md` | Marker-fenced bootstrap snippet appended to `~/.openclaw/workspace/SOUL.md` by `bin/lib/openclaw.js`. Drives always-on caveman through the OpenClaw gateway. Must include the SENTINEL `Respond terse like smart caveman` and stay well under OpenClaw's 12K-per-bootstrap-file cap. |
| `bin/lib/openclaw.js` | OpenClaw install/uninstall helper. Frontmatter merge (`version`, `always: true`), SOUL.md marker append/strip, idempotent. Shared by `bin/install.js` and `src/tools/caveman-init.js`. |
| `src/rules/caveman-openclaw-bootstrap.md` | Marker-fenced bootstrap snippet appended to `~/.openclaw/workspace/SOUL.md` by `cli/lib/openclaw.js`. Drives always-on caveman through the OpenClaw gateway. Must include the SENTINEL `Respond terse like smart caveman` and stay well under OpenClaw's 12K-per-bootstrap-file cap. |
| `cli/lib/openclaw.js` | OpenClaw install/uninstall helper. Frontmatter merge (`version`, `always: true`), SOUL.md marker append/strip, idempotent. Shared by `cli/install.js` and `src/tools/caveman-init.js`. |
| `skills/caveman-commit/SKILL.md` | Caveman commit message behavior. Fully independent skill. |
| `skills/caveman-review/SKILL.md` | Caveman code review behavior. Fully independent skill. |
| `skills/caveman-help/SKILL.md` | Quick-reference card. One-shot display, not a persistent mode. |
@@ -161,9 +161,9 @@ Exports:
### `src/hooks/caveman-activate.js` — SessionStart hook
Runs once per Claude Code session start. Three things:
1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.caveman-active` via `safeWriteFlag` (creates if missing)
1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.caveman-active` via `safeWriteFlag` (creates if missing). Branches on the hook payload's `source` field (#691): `startup` resets to the configured default; `resume`/`clear`/`compact` re-fires preserve a valid existing flag so mid-session `/caveman <level>` switches survive.
2. Emits caveman ruleset as hidden stdout — Claude Code injects SessionStart hook stdout as system context, invisible to user
3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup on first interaction
3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup — once per install, gated by a `.caveman-nudge-shown` marker file (#661)
Silent-fails on all filesystem errors — never blocks session start.
@@ -200,11 +200,11 @@ Configured in `settings.json` under `statusLine.command`. PowerShell counterpart
**Plugin install** — hooks wired automatically by plugin system.
**Standalone install**`bin/install.js` (the unified Node installer) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and merges SessionStart + UserPromptSubmit + statusline into `settings.json`. Uses the JSONC-tolerant helpers in `bin/lib/settings.js` so a commented `settings.json` no longer crashes the merge. Defensive `validateHookFields` runs before every write to prevent a single malformed hook from poisoning the entire file (Claude Code Zod silently discards the whole `settings.json` on schema mismatch).
**Standalone install**`cli/install.js` (the unified Node installer) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and merges SessionStart + UserPromptSubmit + statusline into `settings.json`. Uses the JSONC-tolerant helpers in `cli/lib/settings.js` so a commented `settings.json` no longer crashes the merge. Defensive `validateHookFields` runs before every write to prevent a single malformed hook from poisoning the entire file (Claude Code Zod silently discards the whole `settings.json` on schema mismatch).
The `install.sh` / `install.ps1` shims at the repo root delegate to `bin/install.js` via `node` (local clone) or `npx -y github:JuliusBrussee/caveman` (curl|bash). No legacy fallback path remains — earlier `install.sh.legacy` / `install.ps1.legacy` files were removed.
The `install.sh` / `install.ps1` shims at the repo root delegate to `cli/install.js` via `node` (local clone) or `npx -y github:JuliusBrussee/caveman` (curl|bash). No legacy fallback path remains — earlier `install.sh.legacy` / `install.ps1.legacy` files were removed.
**Uninstall**`npx -y github:JuliusBrussee/caveman -- --uninstall` (or `node bin/install.js --uninstall` from a clone). Strips caveman hook entries from `settings.json` via substring marker `caveman`, deletes hook files, and removes the Claude plugin / Gemini extension. Skill installs done via `npx skills add` must be removed via the IDE's skill manager (we don't track them).
**Uninstall**`npx -y github:JuliusBrussee/caveman -- --uninstall` (or `node cli/install.js --uninstall` from a clone). Strips caveman hook entries from `settings.json` via substring marker `caveman`, deletes hook files, and removes the Claude plugin / Gemini extension. Also removes state files from `$CLAUDE_CONFIG_DIR` (`.caveman-active`, `.caveman-active.prev`, `.caveman-mode-log.jsonl`, `.caveman-statusline-suffix`, `.caveman-nudge-shown`); keeps `.caveman-history.jsonl` (lifetime savings data) with a printed note (#635). Skill installs done via `npx skills add` must be removed via the IDE's skill manager (we don't track them).
---
@@ -244,7 +244,7 @@ How caveman reaches each agent type:
| Codex | Plugin in `plugins/caveman/` plus repo `.codex/hooks.json` and `.codex/config.toml` | Yes on macOS/Linux — SessionStart hook |
| Gemini CLI | Extension with `GEMINI.md` context file | Yes — context file loads every session |
| opencode | Native plugin (`src/plugins/opencode/`) copied into `~/.config/opencode/plugins/caveman/` + `AGENTS.md` ruleset + skills/agents/commands directories. Plugin uses `session.created` and `tui.prompt.append` lifecycle hooks. No statusline (opencode TUI exposes no plugin-writable badge). | Yes — `session.created` writes flag, `AGENTS.md` carries always-on ruleset |
| OpenClaw | Workspace skill at `~/.openclaw/workspace/skills/caveman/SKILL.md` (frontmatter merged with `version` + `always: true`) plus a marker-fenced bootstrap block in `~/.openclaw/workspace/SOUL.md`. Both writes go through `bin/lib/openclaw.js`; workspace path is overridable via `OPENCLAW_WORKSPACE`. | Yes — SOUL.md is auto-injected each turn under "Project Context" (subject to OpenClaw's 12K-per-file / 60K-total bootstrap caps) |
| OpenClaw | Workspace skill at `~/.openclaw/workspace/skills/caveman/SKILL.md` (frontmatter merged with `version` + `always: true`) plus a marker-fenced bootstrap block in `~/.openclaw/workspace/SOUL.md`. Both writes go through `cli/lib/openclaw.js`; workspace path is overridable via `OPENCLAW_WORKSPACE`. | Yes — SOUL.md is auto-injected each turn under "Project Context" (subject to OpenClaw's 12K-per-file / 60K-total bootstrap caps) |
| Cursor | `npx skills add ... -a cursor` (default via `--only cursor`) writes the upstream skill profile; per-repo `.cursor/rules/caveman.mdc` via `--with-init` (calls `src/tools/caveman-init.js`) | Yes — always-on rule |
| Windsurf | `npx skills add ... -a windsurf` (default via `--only windsurf`); per-repo `.windsurf/rules/caveman.md` via `--with-init` | Yes — always-on rule |
| Cline | `npx skills add ... -a cline` (default via `--only cline`); per-repo `.clinerules/caveman.md` via `--with-init` | Yes — Cline auto-discovers `.clinerules/` |
@@ -255,10 +255,10 @@ opencode reaches Tier 1 minus the statusline (opencode's TUI has no plugin-writa
For agents without hook systems, the always-on snippet lives in `INSTALL.md`'s "Want it always on?" section — keep current with `src/rules/caveman-activate.md`.
**Adding a new agent.** Edit the `PROVIDERS` array in `bin/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection).
**Adding a new agent.** Edit the `PROVIDERS` array in `cli/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection).
1. The profile slug must exist in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills). Verify against the README before merging — wrong slugs cause `npx skills add` to fail at runtime, not at install-script load.
2. Run `node bin/install.js --list` to confirm the new row renders correctly.
2. Run `node cli/install.js --list` to confirm the new row renders correctly.
3. Soft probes (config-dir-only) are fine but tag them with `soft: true`. They render with `(soft)` in `--list` so users know detection is best-effort.
---
@@ -292,7 +292,7 @@ To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `.
- Edit `skills/<name>/SKILL.md` for behavior changes. Never edit synced copies under `plugins/caveman/skills/`.
- Edit `src/rules/caveman-activate.md` for auto-activation rule changes. Never edit any per-agent rule copy a user has on their machine.
- Edit `src/rules/caveman-openclaw-bootstrap.md` for the OpenClaw SOUL.md bootstrap snippet. Keep the `<!-- caveman-begin -->` / `<!-- caveman-end -->` markers and the `Respond terse like smart caveman` sentinel — `bin/lib/openclaw.js` keys idempotency off both. If you change the embedded fallback in `bin/lib/openclaw.js`, keep it byte-equivalent to the file.
- Edit `src/rules/caveman-openclaw-bootstrap.md` for the OpenClaw SOUL.md bootstrap snippet. Keep the `<!-- caveman-begin -->` / `<!-- caveman-end -->` markers and the `Respond terse like smart caveman` sentinel — `cli/lib/openclaw.js` keys idempotency off both. If you change the embedded fallback in `cli/lib/openclaw.js`, keep it byte-equivalent to the file.
- Per-skill human docs live in `skills/<name>/README.md`. The LLM-facing body is in `SKILL.md`. Don't merge them — different audiences.
- Build artifacts go in `dist/`. Never check files into `dist/` manually — CI rebuilds them on push, and `dist/` is gitignored.
- README most important file for user-facing impact. Optimize for non-technical readers. Preserve caveman voice.
@@ -301,6 +301,6 @@ To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `.
- CI workflow commits back to main after merge. Account for when checking branch state.
- Hook files must silent-fail on all filesystem errors. Never let hook crash block session start.
- Any new flag file write must go through `safeWriteFlag()` in `caveman-config.js`. Direct `fs.writeFileSync` on predictable user-owned paths reopens the symlink-clobber attack surface.
- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for `bin/install.js` / statusline scripts.
- `bin/install.js` is the only installer source. `install.sh` / `install.ps1` at repo root are 30-line shims that delegate to it. Never re-add per-OS install logic to the shims — that's how we got the Windows quoting bug (#249).
- Any settings.json read in installer or hooks must go through `bin/lib/settings.js` `readSettings()` so JSONC comments don't crash the merge. Any settings.json write must run through `validateHookFields()` first.
- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for `cli/install.js` / statusline scripts.
- `cli/install.js` is the only installer source. `install.sh` / `install.ps1` at repo root are 30-line shims that delegate to it. Never re-add per-OS install logic to the shims — that's how we got the Windows quoting bug (#249).
- Any settings.json read in installer or hooks must go through `cli/lib/settings.js` `readSettings()` so JSONC comments don't crash the merge. Any settings.json write must run through `validateHookFields()` first.
+11 -11
View File
@@ -18,7 +18,7 @@ The repo distributes one skill (caveman) plus a handful of sub-skills
(caveman-commit, caveman-review, caveman-compress, cavecrew-*) to many
agents through different distribution mechanisms (Claude Code plugin, Codex
plugin, Gemini extension, Cursor/Windsurf/Cline rule files, `npx skills` for
the long tail). A single Node installer at `bin/install.js` detects which
the long tail). A single Node installer at `cli/install.js` detects which
agents are on the user's machine and installs the right thing for each.
Sources of truth live at the **top level** of the repo. Agent-specific
@@ -39,10 +39,10 @@ copies live under `plugins/caveman/` and similar mirror dirs — those are
| Cavecrew decision guide (when to delegate to subagents) | `skills/cavecrew/SKILL.md` |
| cavecrew subagent definitions | `agents/cavecrew-investigator.md`, `agents/cavecrew-builder.md`, `agents/cavecrew-reviewer.md` |
| Auto-activation rule body (Cursor/Windsurf/Cline/Copilot) | `src/rules/caveman-activate.md` |
| Add support for a new agent | `bin/install.js` (PROVIDERS array) |
| Add support for a new agent | `cli/install.js` (PROVIDERS array) |
| Per-repo init script (drops rule files into a user's repo) | `src/tools/caveman-init.js` |
| Claude Code hooks | `src/hooks/caveman-activate.js`, `src/hooks/caveman-mode-tracker.js`, `src/hooks/caveman-config.js`, `src/hooks/caveman-statusline.sh`, `src/hooks/caveman-statusline.ps1` |
| Settings.json read/write helpers | `bin/lib/settings.js` |
| Settings.json read/write helpers | `cli/lib/settings.js` |
| MCP shrink server | `src/mcp-servers/caveman-shrink/` |
That's it. Every other markdown file with `SKILL.md` in the path is a copy.
@@ -72,7 +72,7 @@ dotdir mirror, it's a build artifact. Edit the top-level source instead.
## Adding a new agent
The unified Node installer at `bin/install.js` is the **single source of
The unified Node installer at `cli/install.js` is the **single source of
truth** for the supported-agent list. The README and `INSTALL.md` install
tables mirror it by hand — bash and PowerShell shims at the repo root just
delegate to it.
@@ -80,16 +80,16 @@ delegate to it.
1. Confirm the agent has a distribution path. Either:
- it has a profile slug in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills) (most common), or
- it has a native plugin / extension / rule-file mechanism we can target.
2. Append a row to the `PROVIDERS` array in `bin/install.js`. Each row needs:
2. Append a row to the `PROVIDERS` array in `cli/install.js`. Each row needs:
- `id` — short kebab-case identifier (e.g. `windsurf`)
- `label` — human display name (e.g. `Windsurf`)
- `mech` — distribution mechanism (`plugin`, `extension`, `rules-file`, `skills-cli`, …)
- `detect` — clause spec like `command:foo||dir:$HOME/x` describing how to detect the agent
- `profile` — the vercel-labs/skills slug, if applicable
- `soft: true` — set when detection is config-dir-only (best-effort)
3. Run `node bin/install.js --list` and confirm the new row renders correctly. Soft probes should show as `(soft)`.
3. Run `node cli/install.js --list` and confirm the new row renders correctly. Soft probes should show as `(soft)`.
4. Add a row to the install tables in `README.md` and `INSTALL.md`.
5. No CI changes needed — the workflow re-reads `bin/install.js` automatically.
5. No CI changes needed — the workflow re-reads `cli/install.js` automatically.
Bad slug? `npx skills add` fails at install **runtime**, not at install-script
load. Always verify the slug against the vercel-labs/skills README before
@@ -174,18 +174,18 @@ PR descriptions don't need to be long. Caveman style fine. Just say what change,
A handful of invariants that have bitten us before. Keep them.
- **Hooks must silent-fail on filesystem errors.** A `try/catch` that swallows the error is correct here. A hook that throws blocks Claude Code session start — that's user-facing breakage. See existing patterns in `src/hooks/caveman-activate.js`.
- **Settings.json reads and writes go through `bin/lib/settings.js`.** It tolerates JSONC comments. Direct `JSON.parse` on a user's `settings.json` will crash on a single `// comment`.
- **Validate hook entries before writing.** Use `validateHookFields()` in `bin/lib/settings.js`. Claude Code's Zod schema silently discards the **entire** `settings.json` on a single bad hook entry — one malformed write poisons the user's whole config.
- **Settings.json reads and writes go through `cli/lib/settings.js`.** It tolerates JSONC comments. Direct `JSON.parse` on a user's `settings.json` will crash on a single `// comment`.
- **Validate hook entries before writing.** Use `validateHookFields()` in `cli/lib/settings.js`. Claude Code's Zod schema silently discards the **entire** `settings.json` on a single bad hook entry — one malformed write poisons the user's whole config.
- **Symlink-safe flag writes via `safeWriteFlag()`** in `src/hooks/caveman-config.js`. The flag file lives at a predictable path under `$CLAUDE_CONFIG_DIR/`; without `O_NOFOLLOW` and a parent-symlink check, a local attacker can clobber any file the user can write.
- **Honor `CLAUDE_CONFIG_DIR`.** Hooks, the installer, and the statusline scripts must respect it — never hardcode `~/.claude`.
- **`install.sh` and `install.ps1` at the repo root are 30-line shims** that delegate to `bin/install.js`. Don't re-add per-OS install logic to them. Quoting bugs that way lie.
- **`install.sh` and `install.ps1` at the repo root are 30-line shims** that delegate to `cli/install.js`. Don't re-add per-OS install logic to them. Quoting bugs that way lie.
---
## Ideas
See [issues labeled `good first issue`](../../issues?q=label%3A%22good+first+issue%22)
for starter tasks. Or grep `TODO` / `FIXME` in `src/hooks/`, `bin/`, `src/tools/` —
for starter tasks. Or grep `TODO` / `FIXME` in `src/hooks/`, `cli/`, `src/tools/` —
each one is a real lead.
Caveman like contribution. You bring rock, caveman put rock in pile. Pile
+17 -17
View File
@@ -40,10 +40,10 @@ If you want to install for one agent (or want to know exactly what command runs
| Agent | Install command | Auto-activates? |
|---|---|:-:|
| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` | Yes |
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` | Yes |
| **opencode** | `node bin/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman --consent` | Yes |
| **opencode** | `node cli/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
| **OpenClaw** | `npx -y github:JuliusBrussee/caveman -- --only openclaw` | Yes (workspace skill + SOUL.md) |
| **Hermes Agent** | `npx -y github:JuliusBrussee/caveman -- --only hermes` *(or `node bin/install.js --only hermes` from a clone)* | Yes (native skills, enabled on load) |
| **Hermes Agent** | `npx -y github:JuliusBrussee/caveman -- --only hermes` *(or `node cli/install.js --only hermes` from a clone)* | Yes (native skills, enabled on load) |
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Per-session: `/caveman` |
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Per-session by default; `--with-init` for an always-on rule file |
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Per-session by default; `--with-init` for an always-on rule file |
@@ -83,14 +83,14 @@ For "auto-activates? No" agents, type `/caveman` once per session (or use natura
```bash
# Either of these works (install.sh / install.ps1 are thin shims that
# forward all flags to bin/install.js):
# forward all flags to cli/install.js):
bash install.sh --list # macOS / Linux / WSL, from a local clone
pwsh install.ps1 --list # Windows / PowerShell, from a local clone
node bin/install.js --list # any platform, from a local clone
node cli/install.js --list # any platform, from a local clone
npx -y github:JuliusBrussee/caveman -- --list # no clone needed
```
Each row prints the agent id, profile slug (where applicable), and whether it was auto-detected on your machine. Full agent matrix (with detection rules) is also defined in `bin/install.js` under the `PROVIDERS` array.
Each row prints the agent id, profile slug (where applicable), and whether it was auto-detected on your machine. Full agent matrix (with detection rules) is also defined in `cli/install.js` under the `PROVIDERS` array.
## Manual install (no `curl | bash`)
@@ -102,13 +102,13 @@ git clone https://github.com/JuliusBrussee/caveman.git
cd caveman
# Preview every command the installer would run
node bin/install.js --dry-run --all
node cli/install.js --dry-run --all
# Inspect the agent matrix
node bin/install.js --list
node cli/install.js --list
# Install for everything detected
node bin/install.js --all
node cli/install.js --all
```
Useful flags:
@@ -120,7 +120,7 @@ Useful flags:
| `--only <id>` | One agent only. Repeatable: `--only claude --only cursor`. |
| `--dry-run` | Print every command. Write nothing. |
| `--with-init` | Drop always-on rule files into the current repo (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, `.opencode/AGENTS.md`, `AGENTS.md`) and, if OpenClaw is on the box, append the bootstrap block to `~/.openclaw/workspace/SOUL.md`. |
| `--with-mcp-shrink="<upstream cmd>"` | Register `caveman-shrink` MCP proxy wrapping the given upstream MCP server. **Off by default.** A value is required — caveman-shrink is a proxy and exits immediately without one. Example: `--with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp"`. The value is split on whitespace; for paths-with-spaces, install via `node bin/install.js` from a clone or edit `~/.claude.json` after a stub install. |
| `--with-mcp-shrink="<upstream cmd>"` | Register `caveman-shrink` MCP proxy wrapping the given upstream MCP server. **Off by default.** A value is required — caveman-shrink is a proxy and exits immediately without one. Example: `--with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp"`. The value is split on whitespace; for paths-with-spaces, install via `node cli/install.js` from a clone or edit `~/.claude.json` after a stub install. |
| `--no-mcp-shrink` | Skip MCP-shrink registration. (Default.) |
| `--with-hooks` / `--no-hooks` | Force-on or force-off the Claude Code hook installer. (Default: on.) |
| `--skip-skills` | Don't run the npx-skills auto-detect fallback when nothing else matched. |
@@ -137,7 +137,7 @@ For agents without a hook system (Cursor, Windsurf, Cline, Copilot, and friends)
```bash
# Drop rule files into the current repo
node bin/install.js --with-init
node cli/install.js --with-init
# Or pull the rule body straight in (manual)
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md \
@@ -153,7 +153,7 @@ After install, three quick checks:
**1. See what got installed.**
```bash
node bin/install.js --list
node cli/install.js --list
```
You should see ~30 rows. Detected agents are marked. Anything you wanted but isn't marked → not detected (likely the binary isn't on `PATH`).
@@ -207,7 +207,7 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
**"I ran the installer but Claude Code isn't talking caveman."**
1. Run `node bin/install.js --list` — confirm `claude` is on the detected list. If not, `claude` isn't on `PATH`. Fix that first.
1. Run `node cli/install.js --list` — confirm `claude` is on the detected list. If not, `claude` isn't on `PATH`. Fix that first.
2. Open `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/settings.json`) and look for `"hooks"` containing `caveman-activate.js` and `caveman-mode-tracker.js`. If missing, re-run with `--force`.
3. Check `$CLAUDE_CONFIG_DIR/.caveman-active` exists with content `full`. If not, the SessionStart hook silent-failed — check `$CLAUDE_CONFIG_DIR/hooks/` for the JS files and try `node $CLAUDE_CONFIG_DIR/hooks/caveman-activate.js < /dev/null` to see if it errors.
4. Restart Claude Code. The SessionStart hook only fires on session start, not mid-session.
@@ -221,7 +221,7 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
**"My `settings.json` got mangled."**
The installer uses a JSONC-tolerant parser (`bin/lib/settings.js`) so comments and trailing commas don't crash the merge. It also runs `validateHookFields()` before every write so a malformed hook can't poison the file. If something still went wrong:
The installer uses a JSONC-tolerant parser (`cli/lib/settings.js`) so comments and trailing commas don't crash the merge. It also runs `validateHookFields()` before every write so a malformed hook can't poison the file. If something still went wrong:
1. Check for a backup at `$CLAUDE_CONFIG_DIR/settings.json.bak` (installer writes one before any merge).
2. If no backup, restore from your shell history or version control.
@@ -233,10 +233,10 @@ Use the rule-file-only path. Hooks are Claude Code-specific; everything else wor
```bash
# Just install for one agent, no Claude hooks
node bin/install.js --only cursor
node cli/install.js --only cursor
# Or write rule files into the current repo only (no global state)
node bin/install.js --with-init --only cursor --only windsurf
node cli/install.js --with-init --only cursor --only windsurf
```
This drops `.cursor/rules/caveman.mdc` (and friends) into your repo. No hooks, no global config, nothing outside the repo.
@@ -254,7 +254,7 @@ The installer doesn't phone home. It writes to:
- Your current working directory (only with `--with-init`) — repo-local rule files.
- `~/.openclaw/workspace/` (only with `--only openclaw` or `--with-init` when OpenClaw is detected) — the one `--with-init` side-effect outside the cwd.
No telemetry. No analytics. Run from a clone or via npx, the installer's own code makes no network calls — files are copied locally. One exception: run detached from any checkout (the rare curl-fallback path), it downloads the hook files from raw.githubusercontent.com pinned to an immutable release tag and verifies each against a SHA-256 manifest before wiring anything. Network requests also happen indirectly through the per-agent CLIs it shells out to — `claude plugin marketplace add`, `claude plugin install`, `gemini extensions install`, `npm view caveman-shrink`, and `npx -y skills add`. Each fetches from its own registry (Anthropic / GitHub / npm). Source: [`bin/install.js`](bin/install.js). After install: zero network calls, ever — full statement in [SECURITY.md](./SECURITY.md#privacy--telemetry).
No telemetry. No analytics. Run from a clone or via npx, the installer's own code makes no network calls — files are copied locally. One exception: run detached from any checkout (the rare curl-fallback path), it downloads the hook files from raw.githubusercontent.com pinned to an immutable release tag and verifies each against a SHA-256 manifest before wiring anything. Network requests also happen indirectly through the per-agent CLIs it shells out to — `claude plugin marketplace add`, `claude plugin install`, `gemini extensions install`, `npm view caveman-shrink`, and `npx -y skills add`. Each fetches from its own registry (Anthropic / GitHub / npm). Source: [`cli/install.js`](cli/install.js). After install: zero network calls, ever — full statement in [SECURITY.md](./SECURITY.md#privacy--telemetry).
---
+43 -15
View File
@@ -8,7 +8,12 @@
<p align="center">
Make your AI coding agent talk like a caveman.<br>
Same answers, <strong>65% fewer output tokens</strong>. Brain still big. Mouth small.
Same answers. <strong>65% fewer output tokens</strong> on prose,<br>
<strong>8.5%</strong> on <a href="#independently-measured-jetbrains-86-tasks">long-horizon agentic coding runs</a>. Brain still big. Mouth small.
</p>
<p align="center">
<a href="https://trendshift.io/repositories/25391?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-25391" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25391" alt="JuliusBrussee%2Fcaveman | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
@@ -16,6 +21,7 @@
<a href="./INSTALL.md"><img src="https://img.shields.io/badge/works_with-30%2B_agents-orange?style=flat" alt="30+ agents"></a>
<a href="https://github.com/JuliusBrussee/caveman/commits/main"><img src="https://img.shields.io/github/last-commit/JuliusBrussee/caveman?style=flat" alt="Last commit"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/JuliusBrussee/caveman?style=flat" alt="License"></a>
<a href="https://skills.sh/JuliusBrussee/caveman"><img src="https://skills.sh/b/JuliusBrussee/caveman"></a>
</p>
<p align="center">
@@ -78,6 +84,8 @@ Same fix. Third of the words. Nothing technical lost.
Caveman no make brain smaller. Caveman make *mouth* smaller. Shrinks what the agent **says**, not what it knows.
That 65% is the prose number, measured on replies like the ones above. On a full agentic coding run, where most of the output is code and tool calls, it's [8.5%](#independently-measured-jetbrains-86-tasks). Same skill, different workload — mechanism below.
## Install
**One command. Finds every agent on your machine. Installs for each.**
@@ -94,30 +102,25 @@ irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | i
~30 seconds. Needs Node ≥18. Skips agents you no have. Safe to re-run.
> [!TIP]
> **Turn it on:** type `/caveman` or say *"talk like caveman"*. **Turn it off:** say *"normal mode"*. On Claude Code, Codex, and Gemini it's already on from message one. No command needed.
<details>
<summary><strong>Install for one agent, or any of 30+ others</strong></summary>
<br>
Every agent has its own path (plugin, extension, rule file, or `npx skills add`). The full per-agent matrix, all flags, dry-run, and uninstall live in **[INSTALL.md](./INSTALL.md)**. A few common ones:
Prefer one agent at a time? Each has its own path:
```bash
# Claude Code plugin
claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman
# Gemini CLI extension
gemini extensions install https://github.com/JuliusBrussee/caveman
gemini extensions install https://github.com/JuliusBrussee/caveman --consent
# Cursor / Windsurf / Cline / Codex / 30+ more, via the skills registry
npx skills add JuliusBrussee/caveman -a cursor
```
**Install broke?** Open your agent in this repo and say: *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent read repo, agent fix own brain. Snake eat tail.
The full per-agent matrix, all flags, dry-run, and uninstall live in **[INSTALL.md](./INSTALL.md)**.
</details>
> [!TIP]
> **Turn it on:** type `/caveman` or say *"talk like caveman"*. **Turn it off:** say *"normal mode"*. On Claude Code, Codex, and Gemini it's already on from message one. No command needed.
**Install broke?** Open your agent in this repo and say: *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent read repo, agent fix own brain. Snake eat tail.
## Pick your grunt
@@ -151,7 +154,7 @@ Six levels. Switch anytime with `/caveman <level>`. Level sticks until you chang
## Benchmarks
Real token counts from the Claude API. Average **65% output reduction** across 10 prompts (range 2287%), measured against default verbose replies. Output tokens only, committed and reproducible in [`benchmarks/`](./benchmarks/) and [`evals/`](./evals/).
Real token counts from the Claude API. Average **65% output reduction** across 10 **chat-style prompts** (range 2287%), measured against default verbose replies. Output tokens only, committed and reproducible in [`benchmarks/`](./benchmarks/) and [`evals/`](./evals/). This is one-question-one-answer, not a full agentic coding run — for that number, see [JetBrains](#independently-measured-jetbrains-86-tasks) below.
<!-- BENCHMARK-TABLE-START -->
| Task | Normal | Caveman | Saved |
@@ -172,6 +175,29 @@ Real token counts from the Claude API. Average **65% output reduction** across 1
> [!IMPORTANT]
> **Honest number warning.** Caveman only shrinks **output** tokens. Input and reasoning tokens are untouched, and the skill itself adds ~11.5k input tokens per turn. So whole-session savings run smaller than the output number, and on already-terse workloads they can go net-negative. The real win is **readability and speed**. Cost savings are the bonus. When caveman wins, when it loses, and how to measure it yourself: **[docs/HONEST-NUMBERS.md](./docs/HONEST-NUMBERS.md)**.
### Independently measured: JetBrains, 86 tasks
JetBrains ran the skill against [86 tasks from SkillsBench](https://blog.jetbrains.com/ai/2026/07/speak-to-ai-agents-like-cavemen-tosave-tokens/) in July 2026 — real coding work, auto-graded by each task's own tests, Claude Code on `claude-sonnet-5`, skill forced on for every reply.
| Workload | Output tokens saved | Measured by |
|---|---:|---|
| Chat-style prose | **65%** | us, table above |
| Agentic coding run | **8.5%** | JetBrains, 86 tasks |
Both numbers are real. They measure different workloads, and the gap is mechanical: caveman compresses narration and leaves code, diffs, tool calls, and error strings byte-exact. In a chat answer, narration is the whole reply. In an agentic run it's the thin layer between tool calls, so that's all there is to squeeze. An output-only skill has a low ceiling on work that is mostly not prose.
Pick the number that matches your workload:
- **Agent writes you prose** — explanations, review, docs, debugging walkthroughs → 65% territory.
- **Agent works a repo unattended** → single digits. Not zero, not 65%.
Quality was unaffected: across 86 auto-graded tasks the two arms were statistically indistinguishable. Small mouth, same brain — checked by someone who didn't ship it.
Two things follow:
- **Agentic bills are mostly input tokens**, which an output-only skill cannot touch by construction. `/caveman-compress` and `caveman-shrink` chip at that side; the skill alone never will.
- **The right number is your number.** JetBrains had to run a full paid benchmark to find out what caveman does on their stack. That's the job [Caveman 2](#caveman-2) exists to do — for yours, continuously.
Turns out short isn't just cheaper. A March 2026 paper, [*Brevity Constraints Reverse Performance Hierarchies in Language Models*](https://arxiv.org/abs/2604.00025), tested 31 models and found that constraining large models to brief answers **improved accuracy by ~26 points** on some benchmarks. Sometimes less word = more correct.
<details>
@@ -264,6 +290,8 @@ Two things happen, no more: a caveman skill lands in the workspace, and a tiny m
Today's savings numbers (including `/caveman-stats`) are local estimates. Caveman 2 measures and verifies them across a whole team — real receipts, real dashboard, real proof the tokens went down. Building it now.
[The JetBrains result](#independently-measured-jetbrains-86-tasks) is the argument for it. 65% and 8.5% are both correct, and neither one is *your* number — one harness, one model, one task set, and your stack is none of those. The fix is not a better README claim, ours or anyone's. It's a baseline on your own traffic and a receipt at the end of the month.
[**Join the waitlist → caveman.so**](https://caveman.so)
## How it works
@@ -305,7 +333,7 @@ Caveman free forever. Sponsors keep the rock sharp.
Caveman save you token, save you money. Star cost zero. Fair trade. ⭐
[![Star History Chart](https://api.star-history.com/svg?repos=JuliusBrussee/caveman&type=Date)](https://star-history.com/#JuliusBrussee/caveman&Date)
[![Star History Chart](./docs/assets/star-history.png)](https://star-history.com/#JuliusBrussee/caveman&Date)
---
+1 -1
View File
@@ -42,5 +42,5 @@ Caveman is self-contained after install and fully functional offline. There is n
## About scanner warnings
- **Windows Defender / SmartScreen on `install.ps1` (#383):** piping a script from the internet into `iex` and writing into agent config directories matches generic dropper heuristics, so AV tools may warn. The script is short and readable in this repo; the hook files it installs are SHA-256-verified against the pinned release manifest. If you'd rather not pipe-to-shell, clone the repo and run `node bin/install.js` — same result, fully inspectable first.
- **Windows Defender / SmartScreen on `install.ps1` (#383):** piping a script from the internet into `iex` and writing into agent config directories matches generic dropper heuristics, so AV tools may warn. The script is short and readable in this repo; the hook files it installs are SHA-256-verified against the pinned release manifest. If you'd rather not pipe-to-shell, clone the repo and run `node cli/install.js` — same result, fully inspectable first.
- **Snyk "High Risk" on `caveman-compress` (#28):** the compress skill instructs the agent to read a file you name, rewrite it in place, and save a backup. In-place file rewriting is exactly what generic risk scoring flags. It is a real capability, not hidden — but there is no network access, no shell execution beyond what's documented in [`skills/caveman-compress/`](./skills/caveman-compress/), and it never touches files you didn't name.
+92 -22
View File
@@ -7,7 +7,7 @@
// that previously broke the JSON merge step (issue #249).
//
// Distribution:
// Local clone: node bin/install.js [flags]
// Local clone: node cli/install.js [flags]
// curl|bash: delegated from install.sh shim → npx -y github:JuliusBrussee/caveman -- [flags]
// Windows: pwsh install.ps1 [flags] → same npx delegation
//
@@ -33,7 +33,11 @@ const REPO = 'JuliusBrussee/caveman';
// the new tag on every release (CI release step) AFTER regenerating
// src/hooks/checksums.sha256 so the integrity manifest matches the ref.
// Overridable via CAVEMAN_REF for testing against a branch.
const PINNED_REF = process.env.CAVEMAN_REF || 'v1.9.1';
const PINNED_REF = process.env.CAVEMAN_REF || 'v1.10.0';
// OpenClaw skill frontmatter wants a bare semver, not a `v`-prefixed tag —
// derive it from PINNED_REF so the two never drift (was hardcoded separately
// in cli/lib/openclaw.js as '1.0.0').
const OPENCLAW_SKILL_VERSION = PINNED_REF.replace(/^v/, '');
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${PINNED_REF}`;
const HOOKS_REMOTE = `${RAW_BASE}/src/hooks`;
const INIT_SCRIPT_URL = `${RAW_BASE}/src/tools/caveman-init.js`;
@@ -44,6 +48,7 @@ const MCP_SHRINK_PKG = 'caveman-shrink';
const HOOK_FILES = [
'package.json',
'caveman-config.js',
'caveman-parse.js',
'caveman-activate.js',
'caveman-mode-tracker.js',
'caveman-stats.js',
@@ -59,7 +64,7 @@ function parseArgs(argv) {
withHooks: 'auto', withInit: false, withMcpShrink: false,
all: false, minimal: false, listOnly: false, noColor: false,
only: [], uninstall: false, nonInteractive: false,
configDir: null, help: false,
configDir: null, help: false, always: true,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
@@ -84,6 +89,10 @@ function parseArgs(argv) {
case '--with-hooks': opts.withHooks = true; break;
case '--no-hooks': opts.withHooks = false; break;
case '--with-init': opts.withInit = true; break;
// OpenClaw only. Skips `always: true` frontmatter + the SOUL.md
// bootstrap append — the skill installs load-on-demand instead of
// always-on. Default (no flag) behavior is unchanged.
case '--no-always': opts.always = false; break;
case '--with-mcp-shrink': {
const v = argv[i + 1];
if (v && !v.startsWith('--')) {
@@ -372,7 +381,7 @@ function safeStat(p, method) {
// ── Repo root resolution ───────────────────────────────────────────────────
function detectRepoRoot() {
// bin/install.js sits at <repo>/bin/install.js. Walk up one.
// cli/install.js sits at <repo>/cli/install.js. Walk up one.
const here = path.dirname(__filename);
const root = path.resolve(here, '..');
if (fs.existsSync(path.join(root, 'src', 'hooks')) &&
@@ -392,15 +401,28 @@ function detectRepoRoot() {
// args with spaces need quoting; we quote them defensively below.
const IS_WIN = process.platform === 'win32';
function quoteWinArg(a) {
if (!IS_WIN) return a;
if (a === '' || /[\s"]/.test(a)) {
// Standard CommandLineToArgvW escaping
// Trigger on whitespace/quote (CommandLineToArgvW escaping needed) OR any
// cmd.exe metacharacter (& | ^ < > % ( )) — spawnXplat runs the assembled
// string through `shell: true`, so an unquoted metacharacter in an
// attacker-influenced arg (e.g. --with-mcp-shrink value, --with-init cwd)
// reaches cmd.exe and can chain a second command. Split out from quoteWinArg
// (which is IS_WIN-gated) and exported unconditionally so tests/installer/
// can exercise the quoting decision on any host platform, not just Windows.
function winQuoteIfNeeded(a) {
if (a === '' || /[\s"&|^<>%()]/.test(a)) {
// Standard CommandLineToArgvW escaping. Residual: cmd.exe still expands
// %VAR% (and ! under delayed expansion) inside double quotes — env
// expansion, not command chaining; accepted for these install-time args.
return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return a;
}
function quoteWinArg(a) {
if (!IS_WIN) return a;
return winQuoteIfNeeded(a);
}
function spawnXplat(cmd, args, opts) {
if (IS_WIN) {
const quoted = args.map(quoteWinArg).join(' ');
@@ -565,7 +587,10 @@ function installGemini(ctx) {
return;
}
}
const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`], null, opts.dryRun);
// --consent: without it, `gemini extensions install` prints a security
// confirmation prompt and blocks forever on a piped/non-interactive install
// (issue #676) — there's no stdin to answer it from a curl|bash run.
const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`, '--consent'], null, opts.dryRun);
if (spawnOk(r)) results.installed.push('gemini');
else results.failed.push(['gemini', 'gemini extensions install failed']);
process.stdout.write('\n');
@@ -609,7 +634,7 @@ function installHermes(ctx) {
if (!repoRoot) {
warn(' Hermes native install requires a local clone of the caveman repo.');
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only hermes');
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node cli/install.js --only hermes');
results.failed.push(['hermes', 'native install requires local repo clone']);
process.stdout.write('\n');
return;
@@ -661,7 +686,7 @@ const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-revi
const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js';
const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman';
// Marker fence for the opencode AGENTS.md ruleset block. Same convention as
// bin/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when
// cli/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when
// the user has authored content above AND below it.
const OPENCODE_AGENTS_MD_BEGIN = '<!-- caveman-begin -->';
const OPENCODE_AGENTS_MD_END = '<!-- caveman-end -->';
@@ -690,7 +715,7 @@ function installOpencode(ctx) {
if (!repoRoot) {
warn(' opencode native install requires a local clone of the caveman repo.');
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only opencode');
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node cli/install.js --only opencode');
results.failed.push(['opencode', 'native install requires local repo clone']);
process.stdout.write('\n');
return;
@@ -706,7 +731,7 @@ function installOpencode(ctx) {
if (opts.dryRun) {
note(` would mkdir ${pluginDir}/, ${commandsDir}/, ${agentsDir}/, ${skillsDir}/`);
note(` would copy plugin.js + package.json + caveman-config.cjs into ${pluginDir}/`);
note(` would copy plugin.js + package.json + caveman-config.cjs + caveman-parse.cjs into ${pluginDir}/`);
note(` would copy ${OPENCODE_COMMAND_FILES.length} command files into ${commandsDir}/`);
note(` would copy ${OPENCODE_AGENT_FILES.length} cavecrew agents into ${agentsDir}/`);
note(` would copy ${OPENCODE_SKILL_DIRS.length} skill dirs into ${skillsDir}/`);
@@ -730,6 +755,12 @@ function installOpencode(ctx) {
// sibling would be loaded as ESM and break the plugin's require() bridge.
[path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'),
path.join(pluginDir, 'caveman-config.cjs')],
// Shared mode-change parser consumed by both caveman-mode-tracker.js and
// plugin.js's own tui.prompt.append handler. Same .cjs rename reason —
// and caveman-parse.js's own require('./caveman-config') resolves fine
// since both land as siblings in pluginDir.
[path.join(repoRoot, 'src', 'hooks', 'caveman-parse.js'),
path.join(pluginDir, 'caveman-parse.cjs')],
];
for (const [src, dest] of pluginPayload) {
if (fs.existsSync(dest) && !opts.force) {
@@ -882,7 +913,7 @@ function installOpencode(ctx) {
// Drops skills/caveman/ into the OpenClaw workspace and appends a small
// auto-injected bootstrap block to the workspace SOUL.md. Always-on behavior
// comes from SOUL.md (auto-injected each turn); the skill folder makes
// caveman discoverable via `openclaw skills list`. See bin/lib/openclaw.js
// caveman discoverable via `openclaw skills list`. See cli/lib/openclaw.js
// for the actual file writes.
function installOpenclaw(ctx) {
const { say, note, warn, opts, repoRoot, results } = ctx;
@@ -900,6 +931,8 @@ function installOpenclaw(ctx) {
repoRoot,
dryRun: opts.dryRun,
force: opts.force,
version: OPENCLAW_SKILL_VERSION,
always: opts.always,
log,
});
@@ -1176,8 +1209,12 @@ function uninstall(ctx) {
for (const f of HOOK_FILES) {
const p = path.join(hooksDir, f);
if (!fs.existsSync(p)) continue;
if (!opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} }
note(` removed ${p}`);
if (opts.dryRun) {
note(` would remove ${p}`);
} else {
try { fs.unlinkSync(p); } catch (_) {}
note(` removed ${p}`);
}
}
// Don't rmdir hooksDir — other plugins may use it.
}
@@ -1315,9 +1352,32 @@ function uninstall(ctx) {
if (prunedHermes) ok(' pruned caveman skills from Hermes');
}
// Flag file
const flag = path.join(configDir, '.caveman-active');
if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} }
// Flag + per-session state files. `.caveman-active` is the live mode flag;
// the rest are cumulative state the mode-tracker/stats/activate hooks write
// (issue #635 — uninstall only ever cleaned the flag, leaving these behind
// forever). `.caveman-history.jsonl` is the user's lifetime savings ledger —
// deliberately KEPT, not stale state; note it so the user knows it's there.
const STATE_FILES_TO_REMOVE = [
'.caveman-active',
'.caveman-active.prev',
'.caveman-mode-log.jsonl',
'.caveman-statusline-suffix',
'.caveman-nudge-shown',
];
for (const f of STATE_FILES_TO_REMOVE) {
const p = path.join(configDir, f);
if (!fs.existsSync(p)) continue;
if (opts.dryRun) {
note(` would remove ${p}`);
} else {
try { fs.unlinkSync(p); } catch (_) {}
note(` removed ${p}`);
}
}
const historyPath = path.join(configDir, '.caveman-history.jsonl');
if (fs.existsSync(historyPath)) {
note(` kept ${historyPath} (lifetime history — delete manually if unwanted)`);
}
process.stdout.write('\n');
ok('uninstall done.');
@@ -1367,7 +1427,7 @@ function printHelp() {
USAGE
npx -y github:JuliusBrussee/caveman -- [flags]
node bin/install.js [flags]
node cli/install.js [flags]
bash install.sh [flags] # shim npx
pwsh install.ps1 [flags] # shim npx
@@ -1384,6 +1444,9 @@ FLAGS
+ statusline badge. (Default ON.)
--no-hooks Skip the hooks installer.
--with-init Write per-repo IDE rule files into \$PWD.
--no-always OpenClaw only: skip \`always: true\` frontmatter and the
SOUL.md bootstrap append skill loads on demand instead
of always-on. (Default: always-on.)
--with-mcp-shrink="<upstream cmd>"
Claude Code (and opencode): register caveman-shrink MCP
proxy wrapping the given upstream. Default OFF.
@@ -1526,5 +1589,12 @@ async function main() {
return 0;
}
main().then(code => process.exit(code || 0))
.catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); });
// Guard so `require()`-ing this file for unit tests (see tests/installer/)
// doesn't also run the installer as a side effect — only run main() when
// invoked directly as a script (bin entry, `node cli/install.js`, npx).
if (require.main === module) {
main().then(code => process.exit(code || 0))
.catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); });
}
module.exports = { winQuoteIfNeeded, OPENCLAW_SKILL_VERSION };
+31 -10
View File
@@ -67,12 +67,20 @@ function frontmatterHasKey(fm, key) {
return re.test(fm);
}
function mergeOpenclawFrontmatter(src) {
// `opts.version` defaults to SKILL_VERSION (the '1.0.0' fallback) when the
// caller doesn't have a better one on hand — cli/install.js threads through
// PINNED_REF (its release-tag source of truth) instead so the two never
// drift. `opts.always` defaults to true (existing behavior); pass `false`
// (from --no-always) to omit the `always: true` key entirely — the skill
// then loads on demand instead of always-on.
function mergeOpenclawFrontmatter(src, opts = {}) {
const version = opts.version || SKILL_VERSION;
const always = opts.always !== false;
const { frontmatter, body } = splitFrontmatter(src);
const additions = [];
if (!frontmatterHasKey(frontmatter, 'name')) additions.push(`name: ${SKILL_NAME}`);
if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${SKILL_VERSION}`);
if (!frontmatterHasKey(frontmatter, 'always')) additions.push('always: true');
if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${version}`);
if (always && !frontmatterHasKey(frontmatter, 'always')) additions.push('always: true');
if (additions.length === 0 && frontmatter) return src;
const fmBody = (frontmatter ? frontmatter.trimEnd() + '\n' : '') + additions.join('\n') + (additions.length ? '\n' : '');
return '---\n' + fmBody + '---\n' + body;
@@ -198,7 +206,12 @@ function stripBootstrapFromSoul(soulPath) {
}
// ── Public API ────────────────────────────────────────────────────────────
function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog() } = {}) {
// `version` — bare semver stamped into the skill frontmatter; defaults to
// SKILL_VERSION if the caller doesn't pass one (see mergeOpenclawFrontmatter).
// `always` — default true (existing behavior). Pass false (--no-always) to
// skip the `always: true` frontmatter key AND the SOUL.md bootstrap append,
// so the skill installs load-on-demand instead of always-on.
function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog(), version, always = true } = {}) {
const ws = workspace || resolveWorkspace();
const skillBody = loadSkillBody(repoRoot);
if (!skillBody) {
@@ -222,19 +235,27 @@ function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, l
const soulFile = path.join(ws, SOUL_FILE);
if (dryRun) {
log.note(` would write ${skillFile} (with version/always frontmatter)`);
log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`);
log.note(` would write ${skillFile} (with version${always ? '/always' : ''} frontmatter)`);
if (always) {
log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`);
} else {
log.note(' --no-always: would skip SOUL.md bootstrap append (skill loads on demand)');
}
return { ok: true, dryRun: true };
}
fs.mkdirSync(skillDir, { recursive: true });
const merged = mergeOpenclawFrontmatter(skillBody);
const merged = mergeOpenclawFrontmatter(skillBody, { version, always });
fs.writeFileSync(skillFile, merged, { mode: 0o644 });
log.write(` installed: ${skillFile}\n`);
const soul = appendBootstrapToSoul(soulFile, snippet);
if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`);
else log.note(` ${soulFile} already contains caveman bootstrap`);
if (always) {
const soul = appendBootstrapToSoul(soulFile, snippet);
if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`);
else log.note(` ${soulFile} already contains caveman bootstrap`);
} else {
log.note(' --no-always: skipped SOUL.md bootstrap append (skill loads on demand via `openclaw skills list`)');
}
return { ok: true };
}
+8 -1
View File
@@ -1,7 +1,7 @@
// caveman — JSONC-tolerant settings.json read/write + defensive hook validation.
//
// Lifted in spirit from gsd-build/get-shit-done's stripJsonComments + readSettings.
// Reused by bin/install.js and (optionally) by hooks/caveman-activate.js so a
// Reused by cli/install.js and (optionally) by hooks/caveman-activate.js so a
// commented settings.json no longer crashes the installer or the runtime hooks.
//
// Public API:
@@ -253,6 +253,13 @@ function rewriteLegacyManagedHookCommands(settings, absoluteNode) {
let rewritten = 0;
const reBare = /^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/;
for (const ev of Object.keys(settings.hooks)) {
// A hook event value that is an object/string (not an array) survives
// JSONC parse untouched — this runs BEFORE validateHookFields in
// installHooks, so it must tolerate malformed input itself rather than
// assume the array shape. Pre-fix, `for...of` on a non-iterable object
// threw a TypeError here and killed the installer mid-run (mirrors the
// guard removeCavemanHooks already has).
if (!Array.isArray(settings.hooks[ev])) continue;
for (const entry of settings.hooks[ev]) {
if (!entry || !Array.isArray(entry.hooks)) continue;
for (const h of entry.hooks) {
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
# Windows install fallback
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run the unified Node installer afterwards: `npx -y github:JuliusBrussee/caveman -- --only claude` (or `node bin/install.js --only claude` from a clone).
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run the unified Node installer afterwards: `npx -y github:JuliusBrussee/caveman -- --only claude` (or `node cli/install.js --only claude` from a clone).
```powershell
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
+7 -4
View File
@@ -1,7 +1,7 @@
# caveman — installer shim (Windows / PowerShell).
#
# Thin wrapper around bin/install.js (the unified Node installer). Every flag
# you'd pass to bin/install.js can be passed here; we just forward them.
# Thin wrapper around cli/install.js (the unified Node installer). Every flag
# you'd pass to cli/install.js can be passed here; we just forward them.
#
# One-line install:
# irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex
@@ -52,7 +52,7 @@ caveman: Node.js (>=18) required. Install:
# because it is null" crash.
if ($PSCommandPath) {
$here = Split-Path -Parent $PSCommandPath
$local = Join-Path $here "bin/install.js"
$local = Join-Path $here "cli/install.js"
if (Test-Path $local) {
& node $local @InstallerArgs
exit $LASTEXITCODE
@@ -67,8 +67,11 @@ caveman: Node.js (>=18) required. Install:
}
# Do NOT pass `--` here — npm 7+ npx already forwards trailing args to the
# package, and a literal `--` was tripping bin/install.js's parseArgs as an
# package, and a literal `--` was tripping cli/install.js's parseArgs as an
# unknown flag.
# npm >=12 defaults allow-git to "none", failing github: specs with
# EALLOWGIT (#698). Scope the override to this invocation.
$env:NPM_CONFIG_ALLOW_GIT = "all"
& npx -y "github:$Repo" @InstallerArgs
exit $LASTEXITCODE
}
+8 -6
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# caveman — installer shim.
#
# Thin wrapper around bin/install.js (the unified Node installer). Every flag
# you'd pass to bin/install.js can be passed here; we just forward them.
# Thin wrapper around cli/install.js (the unified Node installer). Every flag
# you'd pass to cli/install.js can be passed here; we just forward them.
#
# One-line install:
# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash
@@ -39,16 +39,18 @@ fi
# when bash is invoked from stdin (curl | bash), and `set -u` would trip on a
# bare reference — default to empty so the curl-pipe path falls through cleanly.
here="$(cd "$(dirname "${BASH_SOURCE[0]:-}")" 2>/dev/null && pwd)" || here=""
if [ -n "$here" ] && [ -f "$here/bin/install.js" ]; then
exec node "$here/bin/install.js" "$@"
if [ -n "$here" ] && [ -f "$here/cli/install.js" ]; then
exec node "$here/cli/install.js" "$@"
fi
# Curl-pipe path: delegate to npx. We do NOT pass `--` here — npm 7+ npx
# already forwards trailing args to the package, and a literal `--` tripped
# bin/install.js's parseArgs as an unknown flag.
# cli/install.js's parseArgs as an unknown flag.
if ! command -v npx >/dev/null 2>&1; then
echo "caveman: npx required (ships with Node ≥18). Reinstall Node.js." >&2
exit 1
fi
exec npx -y "github:$REPO" "$@"
# npm >=12 defaults allow-git to "none", failing github: specs with EALLOWGIT
# (#698). Scope the override to this one invocation.
NPM_CONFIG_ALLOW_GIT=all exec npx -y "github:$REPO" "$@"
+2 -2
View File
@@ -13,7 +13,7 @@
"url": "https://github.com/JuliusBrussee/caveman/issues"
},
"bin": {
"caveman": "./bin/install.js"
"caveman": "./cli/install.js"
},
"engines": {
"node": ">=18"
@@ -22,7 +22,7 @@
"test": "node --test tests/installer/*.test.mjs"
},
"files": [
"bin/",
"cli/",
"src/",
"agents/",
"skills/",
@@ -11,7 +11,7 @@ description: >
## 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`.
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
@@ -107,5 +107,5 @@ Compressed:
- 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
- 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)
@@ -23,8 +23,8 @@ def count_tokens(text):
def benchmark_pair(orig_path: Path, comp_path: Path):
orig_text = orig_path.read_text()
comp_text = comp_path.read_text()
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)
@@ -9,8 +9,10 @@ Usage:
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import List
@@ -110,6 +112,59 @@ def strip_llm_wrapper(text: str) -> str:
return m.group(2)
return text
def write_text_atomic(path: Path, text: str) -> 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.
"""
data = text.encode("utf-8")
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 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, backup_path: Path) -> 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)."""
try:
write_text_atomic(filepath, text)
except Exception:
print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}")
raise
from .detect import should_compress
from .validate import validate
@@ -246,7 +301,7 @@ def compress_file(filepath: Path) -> bool:
print("Skipping (not natural language)")
return False
original_text = filepath.read_text(errors="ignore")
original_text = filepath.read_text(encoding="utf-8", errors="ignore")
# 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.
@@ -300,8 +355,8 @@ def compress_file(filepath: Path) -> bool:
# 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_path.write_text(original_text)
backup_readback = backup_path.read_text(errors="ignore")
write_text_atomic(backup_path, original_text)
backup_readback = backup_path.read_text(encoding="utf-8", errors="ignore")
if backup_readback != original_text:
print(f"❌ Backup write verification failed: {backup_path}")
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
@@ -310,7 +365,7 @@ def compress_file(filepath: Path) -> bool:
except OSError:
pass
return False
filepath.write_text(compressed)
_write_target(filepath, compressed, backup_path)
# Step 2: Validate + Retry
for attempt in range(MAX_RETRIES):
@@ -328,7 +383,7 @@ def compress_file(filepath: Path) -> bool:
if attempt == MAX_RETRIES - 1:
# Restore original on failure
filepath.write_text(original_text)
_write_target(filepath, original_text, backup_path)
backup_path.unlink(missing_ok=True)
print("❌ Failed after retries — original restored")
return False
@@ -337,6 +392,23 @@ def compress_file(filepath: Path) -> bool:
compressed = call_claude(
build_fix_prompt(original_text, compressed, result.errors)
)
filepath.write_text(compressed)
if compressed is None or not compressed.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(compressed) != anchor:
print("❌ Fix attempt aborted: output does not start with the original's first line.")
print(" Possible preamble leak. Skipping this attempt.")
continue
_write_target(filepath, compressed, backup_path)
return True
@@ -90,7 +90,7 @@ def detect_file_type(filepath: Path) -> str:
# Extensionless files (like CLAUDE.md, TODO) — check content
if not ext:
try:
text = filepath.read_text(errors="ignore")
text = filepath.read_text(encoding="utf-8", errors="ignore")
except (OSError, PermissionError):
return "unknown"
@@ -28,7 +28,7 @@ class ValidationResult:
def read_file(path: Path) -> str:
return path.read_text(errors="ignore")
return path.read_text(encoding="utf-8")
# ---------- Extractors ----------
@@ -95,8 +95,16 @@ def count_bullets(text):
def extract_inline_codes(text):
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
"""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.
"""
text_without_fences = text
for block in extract_code_blocks(text):
text_without_fences = text_without_fences.replace(block, "", 1)
return re.findall(r"`([^`]+)`", text_without_fences)
@@ -8,3 +8,5 @@ description: >
---
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`).
+14 -4
View File
@@ -14,13 +14,19 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
Default: **full**. Switch: `/caveman lite|full|ultra`.
Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
Never drop not/never/no/only/except — flip meaning worse than any token saved. Numbers, units exact.
Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer — never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity.
Preserve user's dominant language exactly — reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language — openings, pre-tool status lines, all — not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them — grammar, not filler; compress politeness/filler instead.
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
@@ -37,7 +43,7 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction — chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
@@ -55,6 +61,8 @@ Example — "Explain database connection pooling."
- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
- wenyan-ultra: "池蓄連,免逐請新開,省握手。"
Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
## Auto-Clarity
Drop caveman when:
@@ -66,6 +74,8 @@ Drop caveman when:
Resume caveman after clear part done.
Example shows FORMAT only — write warning in session language, not example's.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
@@ -75,4 +85,4 @@ Example — destructive op:
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
Persisted outside chat: write normal prose — code, comments, commits, docs, issue/PR/MR text, memory files, third-party messages (/caveman-compress exempt). "stop caveman" or "normal mode": revert. Level persist until changed or session end.
+1 -1
View File
@@ -25,7 +25,7 @@ CLAUDE.md ← compressed (Claude reads this — fewer tokens every sess
CLAUDE.original.md ← human-readable backup (you edit this)
```
Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits.
Original never lost. Backup lives in a data dir, not next to your file — `$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/` (macOS/Linux) or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` (Windows) — so skill auto-loaders don't re-read it as a live file. You can read and edit `.original.md` there. Run skill again to re-compress after edits.
## Benchmarks
+1 -1
View File
@@ -8,7 +8,7 @@
1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument.
2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are read or written.
2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved to 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). Beyond the target file and that backup location, no files are read or written.
### What the skill does NOT do
+2 -2
View File
@@ -11,7 +11,7 @@ description: >
## 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`.
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
@@ -107,5 +107,5 @@ Compressed:
- 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
- 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)
+2 -2
View File
@@ -23,8 +23,8 @@ def count_tokens(text):
def benchmark_pair(orig_path: Path, comp_path: Path):
orig_text = orig_path.read_text()
comp_text = comp_path.read_text()
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)
+78 -6
View File
@@ -9,8 +9,10 @@ Usage:
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import List
@@ -110,6 +112,59 @@ def strip_llm_wrapper(text: str) -> str:
return m.group(2)
return text
def write_text_atomic(path: Path, text: str) -> 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.
"""
data = text.encode("utf-8")
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 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, backup_path: Path) -> 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)."""
try:
write_text_atomic(filepath, text)
except Exception:
print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}")
raise
from .detect import should_compress
from .validate import validate
@@ -246,7 +301,7 @@ def compress_file(filepath: Path) -> bool:
print("Skipping (not natural language)")
return False
original_text = filepath.read_text(errors="ignore")
original_text = filepath.read_text(encoding="utf-8", errors="ignore")
# 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.
@@ -300,8 +355,8 @@ def compress_file(filepath: Path) -> bool:
# 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_path.write_text(original_text)
backup_readback = backup_path.read_text(errors="ignore")
write_text_atomic(backup_path, original_text)
backup_readback = backup_path.read_text(encoding="utf-8", errors="ignore")
if backup_readback != original_text:
print(f"❌ Backup write verification failed: {backup_path}")
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
@@ -310,7 +365,7 @@ def compress_file(filepath: Path) -> bool:
except OSError:
pass
return False
filepath.write_text(compressed)
_write_target(filepath, compressed, backup_path)
# Step 2: Validate + Retry
for attempt in range(MAX_RETRIES):
@@ -328,7 +383,7 @@ def compress_file(filepath: Path) -> bool:
if attempt == MAX_RETRIES - 1:
# Restore original on failure
filepath.write_text(original_text)
_write_target(filepath, original_text, backup_path)
backup_path.unlink(missing_ok=True)
print("❌ Failed after retries — original restored")
return False
@@ -337,6 +392,23 @@ def compress_file(filepath: Path) -> bool:
compressed = call_claude(
build_fix_prompt(original_text, compressed, result.errors)
)
filepath.write_text(compressed)
if compressed is None or not compressed.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(compressed) != anchor:
print("❌ Fix attempt aborted: output does not start with the original's first line.")
print(" Possible preamble leak. Skipping this attempt.")
continue
_write_target(filepath, compressed, backup_path)
return True
+1 -1
View File
@@ -90,7 +90,7 @@ def detect_file_type(filepath: Path) -> str:
# Extensionless files (like CLAUDE.md, TODO) — check content
if not ext:
try:
text = filepath.read_text(errors="ignore")
text = filepath.read_text(encoding="utf-8", errors="ignore")
except (OSError, PermissionError):
return "unknown"
+11 -3
View File
@@ -28,7 +28,7 @@ class ValidationResult:
def read_file(path: Path) -> str:
return path.read_text(errors="ignore")
return path.read_text(encoding="utf-8")
# ---------- Extractors ----------
@@ -95,8 +95,16 @@ def count_bullets(text):
def extract_inline_codes(text):
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
"""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.
"""
text_without_fences = text
for block in extract_code_blocks(text):
text_without_fences = text_without_fences.replace(block, "", 1)
return re.findall(r"`([^`]+)`", text_without_fences)
+1 -1
View File
@@ -49,7 +49,7 @@ Default mode = `full`. Change it:
export CAVEMAN_DEFAULT_MODE=ultra
```
**Config file** (`~/.config/caveman/config.json`):
**Config file** (`~/.config/caveman/config.json` macOS/Linux, `%APPDATA%\caveman\config.json` Windows):
```json
{ "defaultMode": "lite" }
```
+7 -1
View File
@@ -6,7 +6,9 @@ Real session token receipts. No AI estimation.
Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings versus a non-caveman baseline. Numbers come from the JSONL session log on disk — the model itself does not compute or estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the formatted stats as a blocked-decision reason.
Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`).
Output also includes an `Est. rule overhead` and `Est. net` line whenever the savings figure above them is unambiguous (a single benchmarked mode with a known turn count — no guessing across mixed or unattributed spans). Overhead estimates the per-turn INPUT-token cost of the rules the skill injects every turn — default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS` if you've measured your own setup. Net is savings minus that overhead. On short, terse replies this can go negative — caveman's OUTPUT savings don't clear its INPUT cost — and the line says so directly instead of hiding it behind a gross-savings number. Background: `docs/HONEST-NUMBERS.md`.
Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). That badge stays a gross-savings figure on purpose — it is a glanceable summary, not a full accounting; run `/caveman-stats` for the net picture.
## How to invoke
@@ -22,8 +24,12 @@ Input: 12,304 tokens
Output: 3,891 tokens (caveman)
Baseline: 11,247 tokens (estimated without caveman)
Saved: 7,356 tokens (~65%)
Est. rule overhead: 58,750 (input, ~1,250/turn over 47 turns)
Est. net: -51,394 (caveman cost more than it saved for this workload — consider turning it off)
```
(Numbers above are illustrative — see `docs/HONEST-NUMBERS.md` for why short, terse-reply sessions tend to land net-negative even at a healthy output-savings percentage.)
## See also
- [`SKILL.md`](./SKILL.md) — hook contract and mechanics
+2
View File
@@ -8,3 +8,5 @@ description: >
---
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`).
+14 -4
View File
@@ -14,13 +14,19 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
Default: **full**. Switch: `/caveman lite|full|ultra`.
Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
Never drop not/never/no/only/except — flip meaning worse than any token saved. Numbers, units exact.
Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer — never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity.
Preserve user's dominant language exactly — reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language — openings, pre-tool status lines, all — not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them — grammar, not filler; compress politeness/filler instead.
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
@@ -37,7 +43,7 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction — chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
@@ -55,6 +61,8 @@ Example — "Explain database connection pooling."
- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
- wenyan-ultra: "池蓄連,免逐請新開,省握手。"
Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
## Auto-Clarity
Drop caveman when:
@@ -66,6 +74,8 @@ Drop caveman when:
Resume caveman after clear part done.
Example shows FORMAT only — write warning in session language, not example's.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
@@ -75,4 +85,4 @@ Example — destructive op:
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
Persisted outside chat: write normal prose — code, comments, commits, docs, issue/PR/MR text, memory files, third-party messages (/caveman-compress exempt). "stop caveman" or "normal mode": revert. Level persist until changed or session end.
+3 -3
View File
@@ -2,7 +2,7 @@
These hooks are **bundled with the caveman plugin** and activate automatically when the plugin is installed. No manual setup required.
If you installed caveman standalone (without the plugin), the unified Node installer at `bin/install.js` wires them into your `settings.json` for you — run `node bin/install.js --only claude` from a clone, or `npx -y github:JuliusBrussee/caveman -- --only claude` for the curl-pipe path.
If you installed caveman standalone (without the plugin), the unified Node installer at `cli/install.js` wires them into your `settings.json` for you — run `node cli/install.js --only claude` from a clone, or `npx -y github:JuliusBrussee/caveman -- --only claude` for the curl-pipe path.
## What's Included
@@ -34,7 +34,7 @@ The statusline badge shows which caveman mode is active directly in your Claude
If you already have a custom statusline, caveman does not overwrite it and Claude stays quiet. Add the badge snippet to your existing script instead.
**Standalone users:** the unified installer (`bin/install.js`, invoked by the `install.sh` / `install.ps1` shims at the repo root) wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
**Standalone users:** the unified installer (`cli/install.js`, invoked by the `install.sh` / `install.ps1` shims at the repo root) wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
**Manual setup:** If you need to configure it yourself, add one of these to `~/.claude/settings.json`:
@@ -102,7 +102,7 @@ If installed via the standalone Node installer:
```bash
npx -y github:JuliusBrussee/caveman -- --uninstall
# or, from a clone:
node bin/install.js --uninstall
node cli/install.js --uninstall
```
Or manually:
+12 -1
View File
@@ -27,8 +27,19 @@ const AGENT_ENV_MAP = [
];
// Return the plugin root directory given the hooks directory path.
// Plugin layout: <plugin_root>/hooks/<this-file> → plugin root = parent of hooks dir.
// Layouts (#645): plugin/repo checkout puts this file at <root>/src/hooks/
// (agents/ lives two levels up); standalone installs at <config>/hooks/
// (one level up). Prefer CLAUDE_PLUGIN_ROOT, then the first candidate that
// actually contains an agents/ directory.
function resolvePluginRoot(hookDir) {
const candidates = [];
if (process.env.CLAUDE_PLUGIN_ROOT) candidates.push(process.env.CLAUDE_PLUGIN_ROOT);
candidates.push(path.resolve(hookDir, '..', '..'), path.resolve(hookDir, '..'));
for (const root of candidates) {
try {
if (fs.statSync(path.join(root, 'agents')).isDirectory()) return root;
} catch (e) {}
}
return path.resolve(hookDir, '..');
}
+31 -4
View File
@@ -9,7 +9,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode, safeWriteFlag, recordModeChange } = require('./caveman-config');
const { getDefaultMode, safeWriteFlag, recordModeChange, readFlag, VALID_MODES } = require('./caveman-config');
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
@@ -22,7 +22,30 @@ try {
applyOverrides(resolvePluginRoot(__dirname));
} catch (e) {}
const mode = getDefaultMode();
// SessionStart re-fires mid-conversation (resume, /clear, context compaction),
// not just at true session start. Re-firing must not clobber a mode the user
// switched to mid-session (#691): branch on the hook payload's `source` field —
// only a real `startup` resets to the configured default; resume/clear/compact
// preserve a valid existing flag.
// Sync stdin read assumes the parent (Claude Code) writes the payload and
// closes the pipe — it always does. A parent that held the pipe open forever
// would block here; no such caller exists, and a TTY (manual run) skips it.
let source = 'startup';
try {
if (!process.stdin.isTTY) {
const raw = fs.readFileSync(0, 'utf8');
if (raw) {
const data = JSON.parse(raw);
if (data && typeof data.source === 'string') source = data.source;
}
}
} catch (e) { /* no/bad stdin → treat as startup */ }
let mode = getDefaultMode();
if (source !== 'startup') {
const existing = readFlag(flagPath);
if (existing && VALID_MODES.includes(existing)) mode = existing;
}
// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
@@ -139,7 +162,10 @@ if (skillContent) {
'Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.';
}
// 3. Detect missing statusline config — nudge Claude to help set it up
// 3. Detect missing statusline config — nudge Claude to help set it up.
// One-shot (#661): the nudge costs ~90 tokens per session, so a marker file
// gates it to the first session only. Users who declined stop paying for it.
const nudgeMarkerPath = path.join(claudeDir, '.caveman-nudge-shown');
try {
let hasStatusline = false;
if (fs.existsSync(settingsPath)) {
@@ -149,7 +175,8 @@ try {
}
}
if (!hasStatusline) {
if (!hasStatusline && !fs.existsSync(nudgeMarkerPath)) {
safeWriteFlag(nudgeMarkerPath, '1');
const isWindows = process.platform === 'win32';
const scriptName = isWindows ? 'caveman-statusline.ps1' : 'caveman-statusline.sh';
const scriptPath = path.join(__dirname, scriptName);
+46 -11
View File
@@ -87,7 +87,13 @@ function readModeFromConfigFile(configPath) {
return null;
}
function getDefaultMode() {
// startDir overrides the cwd the repo-config walk starts from (default
// process.cwd(), same as findRepoConfigPath's own fallback) — lets a caller
// resolve the mode for a directory other than its own process cwd (#634:
// the UserPromptSubmit hook's stdin carries the session's cwd, which can
// differ from the hook process's cwd). Every other resolution step is
// cwd-independent, so only the repo-config walk takes it.
function getDefaultMode(startDir) {
// 1. Environment variable (highest priority)
const envMode = process.env.CAVEMAN_DEFAULT_MODE;
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
@@ -95,7 +101,7 @@ function getDefaultMode() {
}
// 2. Repo-local config (checked-in, per-project default)
const repoConfigPath = findRepoConfigPath(process.cwd());
const repoConfigPath = findRepoConfigPath(startDir);
if (repoConfigPath) {
const repoMode = readModeFromConfigFile(repoConfigPath);
if (repoMode) return repoMode;
@@ -178,18 +184,47 @@ function safeWriteFlag(flagPath, content) {
if (e.code !== 'ENOENT') return;
}
const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
let fd;
// tempPath is hoisted above the try so the finally below can always find
// it. On Windows, renameSync onto an existing target throws EPERM/EBUSY/
// EACCES/EEXIST when another process (statusline read, a concurrent
// session's hook) holds the file open without FILE_SHARE_DELETE —
// without the retry + guaranteed cleanup here, every such miss left an
// orphaned .caveman-active.<pid>.<ts> file behind (#511/#578/#657).
let tempPath;
try {
fd = fs.openSync(tempPath, flags, 0o600);
fs.writeSync(fd, String(content));
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
let fd;
try {
fd = fs.openSync(tempPath, flags, 0o600);
fs.writeSync(fd, String(content));
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
// Retry brief lock contention a few times. A missed write is harmless
// (concurrent writers publish the same mode value); a leaked temp file
// is not, so the finally below always sweeps it regardless of outcome.
let renamed = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.renameSync(tempPath, realFlagPath);
renamed = true;
break;
} catch (e) {
const transient = e.code === 'EPERM' || e.code === 'EBUSY' ||
e.code === 'EACCES' || e.code === 'EEXIST';
if (!transient) throw e;
}
}
if (!renamed && debug) {
process.stderr.write('[caveman] safeWriteFlag: rename contended after 3 attempts; flag not updated this write\n');
}
} finally {
if (fd !== undefined) fs.closeSync(fd);
if (tempPath) { try { fs.unlinkSync(tempPath); } catch (e) { /* renamed already, or never created */ } }
}
fs.renameSync(tempPath, realFlagPath);
} catch (e) {
// Silent fail — flag is best-effort
}
+76 -109
View File
@@ -6,11 +6,8 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange, VALID_MODES } = require('./caveman-config');
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
// selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange } = require('./caveman-config');
const { parseModeChange, INDEPENDENT_MODES } = require('./caveman-parse');
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
@@ -29,53 +26,51 @@ process.stdin.on('end', () => {
const data = JSON.parse(input);
// Collapse whitespace so phrase triggers still match multiline prompts —
// every regex below sees a single-line prompt (#598).
const prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' ');
let prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' ');
// Deactivation intent — computed FIRST so "turn caveman mode off" never
// falls through to the activation patterns (#598: the old contiguous
// "turn off" phrasing missed the "turn X off" word order entirely, and
// the activation regex then re-armed caveman at the default level).
const wantsOff =
/\b(stop|disable|deactivate|quit|exit|kill)\s+(the\s+)?caveman\b/.test(prompt) ||
/\bcaveman(\s+mode)?\s+(off|stop|disabled?)\b/.test(prompt) ||
/\bturn\s+off\s+(the\s+)?caveman\b/.test(prompt) ||
// "normal mode" only as a command (prompt-initial, optionally led by a
// switch-back verb) or with caveman context — never mid-sentence for
// e.g. vim's normal mode ("how do I exit vim normal mode").
/^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+|return\s+to\s+)?normal\s+mode\b/.test(prompt) ||
(/\bnormal\s+mode\b/.test(prompt) && /\bcaveman\b/.test(prompt));
// Unattended scheduled-task runs must never receive caveman styling —
// the per-turn reinforcement would hijack the task prompt, and a
// lightweight scheduled task would answer with a caveman greeting
// instead of doing its job. Claude Code wraps these in a
// <scheduled-task ...> marker; bail out completely when present: no flag
// mutation, no reinforcement, no stats. Interactive sessions are
// unaffected.
if (/<scheduled-task\b/.test(prompt)) return;
// Questions about caveman are not activation commands
// ("what is caveman mode?", "does caveman lite drop articles?").
const isQuestion =
/^(what|whats|what's|how|why|when|where|who|does|do|did|is|are|can|could|would|should|tell me|explain)\b/.test(prompt);
// Natural language activation (e.g. "activate caveman", "turn on caveman
// mode", "talk like caveman"). README tells users they can say these.
// Also brevity requests ("less tokens", "be brief/terse", "fewer tokens",
// "shorter answers") — but not when scoped to a single section
// ("be brief in the summary"), which is a one-off instruction, not a
// session-wide mode switch.
if (!wantsOff && !isQuestion) {
if (/\b(activate|enable|start|turn on|use|switch to|want|give me)\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
/\btalk like\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
/\bcaveman\s+mode\s+(on|please|now)\b/.test(prompt) ||
/^caveman(\s+mode)?\s*[.!]*$/.test(prompt) ||
/\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b(?!\s+(in|for|on|about|when|during|with)\b)/.test(prompt)) {
const mode = getDefaultMode();
if (mode !== 'off') {
recordModeChange(claudeDir, mode); // #601: timestamped transition log
safeWriteFlag(flagPath, mode);
}
// Claude Code delivers slash commands to this hook as an envelope, not
// the literal command (#537):
// <command-message>caveman</command-message>
// <command-name>/caveman</command-name>
// <command-args>ultra</command-args>
// (one-line or newline-separated — the collapse above normalizes both
// into single spaces; <command-args> may be empty or absent). Every
// switch below matches against the literal command string, so this
// envelope was a silent no-op for every slash command, including
// '/caveman off'. Reconstruct '<name> <args>' for /caveman* envelopes so
// the rest of this hook sees exactly what the user selected. A foreign
// command's envelope is left untouched, and natural-language detection
// is skipped for it so another command's own args can't misfire our
// activation/deactivation triggers.
let skipNaturalLanguage = false;
const envName = /<command-name>\s*([^<\s]+)\s*<\/command-name>/.exec(prompt);
if (envName) {
if (envName[1].startsWith('/caveman')) {
const envArgs = /<command-args>\s*([^<]*?)\s*<\/command-args>/.exec(prompt);
const args = envArgs ? envArgs[1].trim() : '';
prompt = args ? envName[1] + ' ' + args : envName[1];
} else {
skipNaturalLanguage = true;
}
}
// /caveman-stats [--share] — block the prompt and inject stats output as
// the hook's reason. The script reads the active session log, so we pass
// /caveman-stats [--share] — run the stats script and inject its output
// as additionalContext (#618), instructing the model to relay it
// verbatim. The script reads the active session log, so we pass
// transcript_path through when Claude Code provides it.
const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt);
if (statsMatch) {
const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean);
let block;
try {
const statsPath = path.join(__dirname, 'caveman-stats.js');
const argv = [statsPath];
@@ -86,81 +81,50 @@ process.stdin.on('end', () => {
if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) {
argv.push('--since', tailArgs[sinceIdx + 1]);
}
const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 });
process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() }));
block = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 }).trim();
} catch (e) {
process.stdout.write(JSON.stringify({
decision: 'block',
reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js'
}));
block = 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js';
}
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: 'Print this stats block verbatim inside a fenced code block. Say nothing else.\n\n' + block
}
}));
return;
}
// Match /caveman commands. Independent one-shot modes remember the prose
// mode active before them so the next ordinary prompt restores it (#599)
// — SKILL.md promises "Level persist until changed or session end", and a
// one-shot skill invocation should not count as "changed" forever.
// Shared mode-change parser (#602) — single source of truth with the
// opencode plugin for slash commands, namespaced /caveman:caveman-*,
// natural-language activation/deactivation, and brevity triggers.
const change = parseModeChange(prompt, { getDefaultMode, skipNaturalLanguage });
// Independent one-shot modes remember the prose mode active before them
// so the next ordinary prompt restores it (#599) — SKILL.md promises
// "Level persist until changed or session end", and a one-shot skill
// invocation should not count as "changed" forever.
let setIndependentThisTurn = false;
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
const arg = parts[1] || '';
let mode = null;
// Marketplace plugin installs surface commands namespaced as
// /caveman:caveman-<name> — accept both forms for every skill (#599:
// only compress and stats had the namespaced variant).
if (cmd === '/caveman-commit' || cmd === '/caveman:caveman-commit') {
mode = 'commit';
} else if (cmd === '/caveman-review' || cmd === '/caveman:caveman-review') {
mode = 'review';
} else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
mode = 'compress';
} else if (cmd === '/caveman' || cmd === '/caveman:caveman') {
// Bare /caveman → activate at configured default
if (!arg) {
mode = getDefaultMode();
} else if (arg === 'off' || arg === 'stop' || arg === 'disable') {
mode = 'off';
} else if (arg === 'wenyan-full') {
// Canonical alias — config stores as 'wenyan'
mode = 'wenyan';
} else if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) {
mode = arg;
if (change && change.action === 'set') {
const mode = change.mode;
if (INDEPENDENT_MODES.has(mode)) {
// Save the prose mode being displaced — but never overwrite an
// already-saved one with another independent mode (/caveman-commit
// followed by /caveman-review must still restore the original).
const current = readFlag(flagPath);
if (current && !INDEPENDENT_MODES.has(current)) {
safeWriteFlag(prevPath, current);
}
// Unknown arg → mode stays null, flag untouched (no silent overwrite)
setIndependentThisTurn = true;
}
if (mode && mode !== 'off') {
if (INDEPENDENT_MODES.has(mode)) {
// Save the prose mode being displaced — but never overwrite an
// already-saved one with another independent mode (/caveman-commit
// followed by /caveman-review must still restore the original).
const current = readFlag(flagPath);
if (current && !INDEPENDENT_MODES.has(current)) {
safeWriteFlag(prevPath, current);
}
setIndependentThisTurn = true;
}
recordModeChange(claudeDir, mode); // #601
safeWriteFlag(flagPath, mode);
} else if (mode === 'off') {
recordModeChange(claudeDir, null); // #601
try { fs.unlinkSync(flagPath); } catch (e) {}
try { fs.unlinkSync(prevPath); } catch (e) {}
}
}
// Apply deactivation detected above
if (wantsOff) {
recordModeChange(claudeDir, mode); // #601: timestamped transition log
safeWriteFlag(flagPath, mode);
} else if (change && change.action === 'clear') {
recordModeChange(claudeDir, null); // #601
try { fs.unlinkSync(flagPath); } catch (e) {}
try { fs.unlinkSync(prevPath); } catch (e) {}
}
// Per-turn reinforcement: emit a structured reminder when caveman is active.
// Per-turn reinforcement: emit a short reminder when caveman is active.
// The SessionStart hook injects the full ruleset once, but models lose it
// when other plugins inject competing style instructions every turn.
// This keeps caveman visible in the model's attention on every user message.
@@ -190,13 +154,16 @@ process.stdin.on('end', () => {
}
}
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
// #634: a repo-local .caveman.json / .caveman/config.json can set
// defaultMode "off" to opt a project out of caveman entirely. Thread the
// hook stdin's cwd through so that check resolves for the session's
// directory, not this hook process's own cwd. This gates ONLY the
// reinforcement output below — it never deletes or writes the flag file.
if (activeMode && !INDEPENDENT_MODES.has(activeMode) && getDefaultMode(data.cwd) !== 'off') {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal."
additionalContext: `CAVEMAN MODE ACTIVE (${activeMode}) — session ruleset applies.`
}
}));
}
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
// caveman — shared mode-change parser (#602)
//
// Single source of truth for interpreting a user prompt as a caveman mode
// change. Extracted from caveman-mode-tracker.js so the Claude Code hook and
// the opencode plugin can't drift out of sync with each other. The tracker's
// regexes are the reference behavior — every pattern below is copied from it
// verbatim; don't tighten or loosen a match here without a matching change
// (and test) on the tracker side.
//
// parseModeChange(prompt, { getDefaultMode, skipNaturalLanguage, expandedTpl, unwrapQuotes })
// → { action: 'set', mode } — caller should activate `mode`
// → { action: 'clear' } — caller should deactivate (delete the flag)
// → null — prompt does not change state
//
// Options:
// getDefaultMode — required; () => resolved default mode string.
// Injected rather than required directly so the
// caller controls resolution (cwd, env, etc.).
// skipNaturalLanguage — when true, skip deactivation/activation/brevity
// phrase matching entirely. Set this for prompt text
// that isn't the user's own words (e.g. a foreign
// slash-command envelope's <command-args>), so
// another command's arguments can't misfire our
// triggers.
// expandedTpl — when true, also recognize opencode's expanded
// command-template bodies (a typed "/caveman ultra"
// gets replaced by the command file's prose before
// this parser ever sees it). Claude Code prompts
// never take this shape, so Claude Code callers
// should leave this off.
// unwrapQuotes — when true, strip a single layer of matching quote
// characters wrapping the whole prompt (opencode's
// non-interactive `run` path delivers messages this
// way).
// Sibling require that tolerates the opencode install layout, where this
// file is copied next to a renamed `caveman-config.cjs` (the plugin dir is
// "type": "module", so a bare `.js` sibling would load as ESM). A plain
// `require('./caveman-config')` only auto-resolves the `.js` extension, so
// try that first (dev tree, standalone hook install) and fall back to the
// explicit `.cjs` name.
let cavemanConfig;
try {
cavemanConfig = require('./caveman-config');
} catch (e) {
cavemanConfig = require('./caveman-config.cjs');
}
const { VALID_MODES } = cavemanConfig;
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
// selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
function parseModeChange(promptRaw, options) {
options = options || {};
const getDefaultMode = options.getDefaultMode || cavemanConfig.getDefaultMode;
let prompt = (promptRaw || '').trim();
if (options.unwrapQuotes) {
const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt);
if (wrapped) prompt = wrapped[2].trim();
}
// Capture the first line before whitespace collapse. The expandedTpl
// templates (opencode's commands/caveman.md etc.) put $ARGUMENTS at the end
// of the first line, followed by a blank line and then fixed boilerplate
// ("If no level given, use full. If \"off\", deactivate."). Collapsing all
// whitespace to single spaces (below) merges an EMPTY argument directly
// into that boilerplate, so a bare `/caveman` with no level looked like the
// level was the word "if" and got rejected as bogus. Extract the template
// argument from this uncollapsed first line instead.
const firstLine = prompt.toLowerCase().split(/\r?\n/, 1)[0];
// Collapse whitespace so phrase triggers still match multiline prompts —
// every regex below expects a single-line prompt (#598).
prompt = prompt.toLowerCase().replace(/\s+/g, ' ');
if (!prompt) return null;
// Deactivation intent — computed FIRST so "turn caveman mode off" never
// falls through to the activation patterns (#598), and applied with the
// highest priority: it's what the tracker's original unconditional
// end-of-function deactivation check amounted to.
const wantsOff = !options.skipNaturalLanguage && (
/\b(stop|disable|deactivate|quit|exit|kill)\s+(the\s+)?caveman\b/.test(prompt) ||
/\bcaveman(\s+mode)?\s+(off|stop|disabled?)\b/.test(prompt) ||
/\bturn\s+off\s+(the\s+)?caveman\b/.test(prompt) ||
// "normal mode" only as a command (prompt-initial, optionally led by a
// switch-back verb) or with caveman context — never mid-sentence for
// e.g. vim's normal mode ("how do I exit vim normal mode").
/^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+|return\s+to\s+)?normal\s+mode\b/.test(prompt) ||
(/\bnormal\s+mode\b/.test(prompt) && /\bcaveman\b/.test(prompt))
);
if (wantsOff) return { action: 'clear' };
// opencode expands a typed "/caveman <level>" (and the independent-mode
// commands) into the command file's prose before chat.message fires, so
// the literal slash-command branch below never sees the original text.
// Recover the level from each template's fixed prefix instead. This MUST
// run before the generic NL-activation match below: "Activate caveman
// mode: ultra" would otherwise trip the "activate ... caveman" trigger and
// swallow the level, activating at the default instead (#602). Claude Code
// prompts never take this shape, so gate behind expandedTpl (opencode-only).
if (options.expandedTpl) {
if (/^generate a commit message for the current staged changes\b/.test(prompt)) {
return { action: 'set', mode: 'commit' };
}
if (/^review the current diff\b/.test(prompt)) {
return { action: 'set', mode: 'review' };
}
if (/^compress the file at:/.test(prompt)) {
return { action: 'set', mode: 'compress' };
}
const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(firstLine);
if (tpl) {
const arg = tpl[1] || '';
if (!arg) {
const mode = getDefaultMode();
return mode === 'off' ? { action: 'clear' } : { action: 'set', mode };
}
if (arg === 'off' || arg === 'stop' || arg === 'disable') return { action: 'clear' };
if (arg === 'wenyan-full') return { action: 'set', mode: 'wenyan' };
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return { action: 'set', mode: arg };
return null; // unknown/bogus level — leave flag untouched (#602)
}
}
if (!options.skipNaturalLanguage) {
// Questions about caveman are not activation commands
// ("what is caveman mode?", "does caveman lite drop articles?").
const isQuestion =
/^(what|whats|what's|how|why|when|where|who|does|do|did|is|are|can|could|would|should|tell me|explain)\b/.test(prompt);
// Natural language activation (e.g. "activate caveman", "turn on caveman
// mode", "talk like caveman"). Also brevity requests ("less tokens",
// "be brief/terse", "fewer tokens", "shorter answers") — but not when
// scoped to a single section ("be brief in the summary"), which is a
// one-off instruction, not a session-wide mode switch.
if (!isQuestion) {
if (/\b(activate|enable|start|turn on|use|switch to|want|give me)\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
/\btalk like\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
/\bcaveman\s+mode\s+(on|please|now)\b/.test(prompt) ||
/^caveman(\s+mode)?\s*[.!]*$/.test(prompt) ||
/\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b(?!\s+(in|for|on|about|when|during|with)\b)/.test(prompt)) {
const mode = getDefaultMode();
// Mirrors the tracker exactly: a configured-off default makes this a
// no-op (leave whatever flag state already exists), NOT a clear —
// that's only what an explicit "/caveman" bare command does.
return mode !== 'off' ? { action: 'set', mode } : null;
}
}
}
// Match /caveman commands. Marketplace plugin installs surface commands
// namespaced as /caveman:caveman-<name> — accept both forms for every
// skill (#599: only compress and stats had the namespaced variant).
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
const arg = parts[1] || '';
if (cmd === '/caveman-commit' || cmd === '/caveman:caveman-commit') {
return { action: 'set', mode: 'commit' };
}
if (cmd === '/caveman-review' || cmd === '/caveman:caveman-review') {
return { action: 'set', mode: 'review' };
}
if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
return { action: 'set', mode: 'compress' };
}
if (cmd === '/caveman' || cmd === '/caveman:caveman') {
// Bare /caveman → activate at configured default
if (!arg) {
const mode = getDefaultMode();
return mode === 'off' ? { action: 'clear' } : { action: 'set', mode };
}
if (arg === 'off' || arg === 'stop' || arg === 'disable') return { action: 'clear' };
if (arg === 'wenyan-full') return { action: 'set', mode: 'wenyan' }; // canonical alias — config stores as 'wenyan'
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return { action: 'set', mode: arg };
// Unknown arg → no-op, flag untouched (no silent overwrite with default)
return null;
}
}
return null;
}
module.exports = { parseModeChange, INDEPENDENT_MODES };
+63 -6
View File
@@ -18,6 +18,21 @@ const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_
// run is committed.
const COMPRESSION = { 'full': 0.65 };
// Per-turn INPUT cost the rules add: SKILL.md (~5 KB) is injected into
// context, plus the per-turn reinforcement the mode tracker emits. This is
// the ~1-1.5k/turn figure docs/HONEST-NUMBERS.md admits and #145/#677 flag as
// hidden — gross output savings alone can look great while the session is
// still net-negative. 1250 sits mid-range; override with
// CAVEMAN_RULE_OVERHEAD_TOKENS if you've measured your own setup.
const DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN = 1250;
function ruleOverheadPerTurn() {
const raw = process.env.CAVEMAN_RULE_OVERHEAD_TOKENS;
if (raw === undefined) return DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN;
}
// Approximate Anthropic public output-token pricing, USD per million.
// Match by model id prefix so this stays correct across point releases
// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from
@@ -250,6 +265,28 @@ function deriveSavings({ byMode, model }) {
return { estSavedTokens, estSavedUsd };
}
// Net token effect = output tokens saved minus the input tokens the rules
// cost. Savings are OUTPUT tokens, overhead is INPUT tokens — different
// buckets, but summing them is the only honest whole-budget delta (see
// docs/HONEST-NUMBERS.md). Never called with an unattributed savings figure —
// callers only invoke this where mode attribution and turn counts both exist.
function deriveNet({ estSavedTokens, turns }) {
const overheadTokens = Math.max(0, turns || 0) * ruleOverheadPerTurn();
return { overheadTokens, netTokens: (estSavedTokens || 0) - overheadTokens };
}
// Shared "rule overhead" + "net" lines for the session and lifetime views.
function netLines({ estSavedTokens, turns }) {
const perTurn = ruleOverheadPerTurn();
const { overheadTokens, netTokens } = deriveNet({ estSavedTokens, turns });
const overhead = `Est. rule overhead: ${overheadTokens.toLocaleString()} ` +
`(input, ~${perTurn.toLocaleString()}/turn over ${turns} turn${turns === 1 ? '' : 's'})`;
const net = netTokens >= 0
? `Est. net: +${netTokens.toLocaleString()} (net saving after rule overhead)`
: `Est. net: ${netTokens.toLocaleString()} (caveman cost more than it saved for this workload — consider turning it off)`;
return `${overhead}\n${net}`;
}
// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input.
function parseDuration(spec) {
if (!spec) return null;
@@ -275,12 +312,22 @@ function aggregateHistory(historyPath, sinceMs) {
if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry);
}
let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0;
// Net (rule-overhead) figures only ever sum rows that actually logged a
// turn count. Legacy history rows predate #145's `turns` field — folding
// their savings into a net computed from someone else's turns would either
// over- or under-state the overhead, so they're excluded from net entirely
// (they still count toward the plain gross totals above, unchanged).
let netSavedTokens = 0, netTurns = 0;
for (const e of latestPerSession.values()) {
outputTokens += e.output_tokens || 0;
estSavedTokens += e.est_saved_tokens || 0;
estSavedUsd += e.est_saved_usd || 0;
if (e.turns != null) {
netSavedTokens += e.est_saved_tokens || 0;
netTurns += e.turns || 0;
}
}
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd };
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd, netSavedTokens, netTurns };
}
// Output-reduction share: saved / (saved + used) = the fraction of the
@@ -306,7 +353,7 @@ function humanizeTokens(n) {
return String(Math.round(n));
}
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) {
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, netSavedTokens, netTurns, since }) {
const sep = '──────────────────────────────────';
const window = since ? ` (last ${since})` : '';
if (sessions === 0) {
@@ -317,11 +364,14 @@ function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, si
const budgetLine = pct !== null
? `Est. output reduction: ~${pct}% (output tokens only, est.)\n`
: '';
// Only sessions that logged a turn count feed the net figure (older rows
// predate #145) — omit rather than understate the overhead.
const netBlock = netTurns > 0 ? netLines({ estSavedTokens: netSavedTokens, turns: netTurns }) + '\n' : '';
return `\nCaveman Stats — Lifetime${window}\n${sep}\n` +
`Sessions: ${sessions.toLocaleString()}\n${sep}\n` +
`Output tokens: ${outputTokens.toLocaleString()}\n` +
`Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` +
budgetLine + usdLine + sep + '\n';
netBlock + budgetLine + usdLine + sep + '\n';
}
// Single-line tweetable summary. Stays human-friendly when no ratio is known.
@@ -414,9 +464,15 @@ function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessio
// any session-usage % would overstate real limit relief. See
// docs/HONEST-NUMBERS.md.
footer += ' Reduction is of output tokens only; input/cache usage is unchanged.';
footer += ` Net subtracts the rules' est. input cost (~${ruleOverheadPerTurn().toLocaleString()}/turn — docs/HONEST-NUMBERS.md).`;
savings = (`Est. without caveman: ${estNormal.toLocaleString()}\n` +
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}% of output)\n` +
usdLine).replace(/\n$/, '');
// Net only makes sense where the savings figure above is unambiguous: a
// single benchmarked mode ran the whole span (uniform) with a known turn
// count. Mixed-mode or partially-unattributed spans (the !uniform branch
// above) intentionally get no net line rather than a guessed one.
if (turns > 0) savings += '\n' + netLines({ estSavedTokens: estSaved, turns });
} else if (mode && mode !== 'off') {
savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
} else {
@@ -501,6 +557,7 @@ function main() {
mode: mode || null,
model: parsed.model || null,
output_tokens: parsed.outputTokens,
turns: parsed.turns,
est_saved_tokens: estSavedTokens,
est_saved_usd: estSavedUsd,
}));
@@ -527,7 +584,7 @@ if (require.main === module) main();
module.exports = {
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M,
findCompressedPairs, summarizeCompressed, humanizeTokens, outputReductionPct,
readModeLog, attributeByMode,
deriveNet, ruleOverheadPerTurn, parseSession, priceForModel, formatUsd, COMPRESSION,
MODEL_OUTPUT_PRICE_PER_M, findCompressedPairs, summarizeCompressed, humanizeTokens,
outputReductionPct, readModeLog, attributeByMode,
};
+4
View File
@@ -48,3 +48,7 @@ if [ "${CAVEMAN_STATUSLINE_SAVINGS:-1}" != "0" ]; then
[ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS"
fi
fi
# An empty suffix file leaves the last [ -n ] test as the script's exit status
# (1), and Claude Code hides the whole status bar on non-zero exit (#711).
exit 0
+7 -6
View File
@@ -1,8 +1,9 @@
8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a package.json
f47fe2e6440578eeb20408bc3131b63fb8e1ae4f6ea26dbdcca33e7901e5ee8c caveman-config.js
bc0af3ba327657630e5f6c60be619e6b5394bf03edc29b1384a15f09a1b348bf caveman-activate.js
3e34f004d4a9e65609f8c95b47dfe5f0e3a6f2d16b88c7f8af730187ee2fb246 caveman-mode-tracker.js
433a345a279c2d54be51864ef77c0a7b19d0f8604b9a441ce0a1eded31f113e8 caveman-stats.js
d2deff457d0a5d8e1848193e6af6a68a0ebdba4fbdf250889400d5ea231e088f caveman-statusline.sh
bece20e2d95b2502606dedc8b3bc329ac769a2f3638e3013d8477cabeab13f34 caveman-config.js
397cf3d243fae04859e0c135f87f456a4972256ae425ee66457da4631ffa509a caveman-parse.js
fea02dc4f0460433a5b892a32ccd4a735eb8bfd8974b592113574c6e55c90370 caveman-activate.js
07a16ec91be50900eaaa5cb24d0518fc77c98b6159102e27c37d43aae1d70640 caveman-mode-tracker.js
f598dde3cc7b701c68547c103a56d566ccc2f75d1c1f3484883ab9f396032b5d caveman-stats.js
4b22120731be5a23f08d0b87d627cd5ac1833d994077d554aa78d7c51a212435 caveman-statusline.sh
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
9f2601e8551653609f0b9d700c9bd75bffa43747d2db18ffe83fb73de5dc3607 cavecrew-model-overrides.js
9b72e18343a5487acde46d795f4871abfae21212b6eeb853d54981aae260bdf1 cavecrew-model-overrides.js
+1 -1
View File
@@ -11,7 +11,7 @@ opencode's `session.created` + `tui.prompt.append` lifecycle hooks.
| `package.json` | Marks the directory as ESM so Bun loads `plugin.js` correctly. |
| `commands/*.md` | Six slash-command prompt templates (`/caveman`, `/caveman-commit`, …). |
The installer (`bin/install.js --only opencode`) copies these alongside
The installer (`cli/install.js --only opencode`) copies these alongside
`src/hooks/caveman-config.js` (for the symlink-safe flag-write helpers, renamed
to `caveman-config.cjs` because this directory is `"type": "module"`) into
`~/.config/opencode/plugins/caveman/` and patches `opencode.json` with a
+37 -83
View File
@@ -7,13 +7,15 @@
//
// Bun ESM module; loads the existing security-hardened helpers from
// caveman-config.js via createRequire so the symlink-safe flag-write code
// lives in one place.
// lives in one place. Same trick loads caveman-parse.js (#602) so the mode-
// change parsing is a single shared source with caveman-mode-tracker.js.
//
// Layout once installed:
// ~/.config/opencode/plugins/caveman/
// ├── package.json
// ├── plugin.js ← this file
// ── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js
// ── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js
// └── caveman-parse.cjs ← copied sibling of src/hooks/caveman-parse.js
//
// The always-on caveman ruleset is provided separately via
// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic
@@ -34,7 +36,7 @@
// https://github.com/JuliusBrussee/caveman/issues/421
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
import os from 'node:os';
@@ -43,7 +45,7 @@ import path from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
// When installed: caveman-config.cjs sits next to plugin.js (copied by
// bin/install.js, renamed to .cjs because this directory's package.json
// cli/install.js, renamed to .cjs because this directory's package.json
// declares "type": "module" — bare .js would be loaded as ESM). When loaded
// from the source tree (tests, dev): fall back to the canonical
// src/hooks/caveman-config.js, which lives in a directory whose own
@@ -62,17 +64,33 @@ function loadConfig() {
const target = existsSync(installed) ? installed : dev;
const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
const mod = { exports: {} };
// Base require on the loaded file, not plugin.js — caveman-parse.js does a
// relative require('./caveman-config') that must resolve against src/hooks/
// in the dev layout and against pluginDir when installed.
new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
mod, mod.exports, createRequire(import.meta.url), dirname(target), target
mod, mod.exports, createRequire(pathToFileURL(target).href), dirname(target), target
);
return mod.exports;
}
const config = loadConfig();
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config;
const { getDefaultMode, safeWriteFlag, readFlag } = config;
// Modes handled by independent skills — not selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
// Load the shared mode-change parser (#602) the same way loadConfig() loads
// caveman-config.js — see the doc comment above loadConfig() for why this
// can't go through require()/import() in a compiled Bun binary.
function loadParse() {
const installed = join(here, 'caveman-parse.cjs');
const dev = join(here, '..', '..', 'hooks', 'caveman-parse.js');
const target = existsSync(installed) ? installed : dev;
const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
const mod = { exports: {} };
new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
mod, mod.exports, createRequire(pathToFileURL(target).href), dirname(target), target
);
return mod.exports;
}
const { parseModeChange, INDEPENDENT_MODES } = loadParse();
// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode
// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode
@@ -88,85 +106,18 @@ function opencodeConfigDir() {
const flagPath = path.join(opencodeConfigDir(), '.caveman-active');
function reinforcementLine(mode) {
return 'CAVEMAN MODE ACTIVE (' + mode + '). ' +
'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
'Code/commits/security: write normal.';
return 'CAVEMAN MODE ACTIVE (' + mode + ') — session ruleset applies.';
}
// Parse a prompt for slash-command activation or natural-language toggles.
// Returns the new mode to write, the literal string 'off' to deactivate, or
// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js.
function parseModeChange(promptRaw) {
let prompt = (promptRaw || '').trim();
// opencode's non-interactive `run` path delivers the message wrapped in
// literal quote characters ("/caveman ultra"\n) — unwrap symmetric quotes
// so the slash-command branch still matches.
const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt);
if (wrapped) prompt = wrapped[2].trim();
prompt = prompt.toLowerCase();
if (!prompt) return null;
// Natural-language deactivation — checked before activation so "stop talking
// like caveman" doesn't trip the activation regex.
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
/\bnormal mode\b/i.test(prompt)) {
return 'off';
}
// Expanded /caveman command template. opencode replaces a typed
// "/caveman <level>" with the command file's body ("Activate caveman
// mode: $ARGUMENTS ...") before chat.message fires, so the literal
// slash-command branch below never sees it — recover the level argument
// from the template's first line instead. Must run before the generic
// NL-activation match, which would swallow it and drop the level.
const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(prompt);
if (tpl) {
const arg = tpl[1] || '';
if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
if (arg === 'wenyan-full') return 'wenyan';
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
return getDefaultMode();
}
// Natural-language activation
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
const mode = getDefaultMode();
return mode === 'off' ? null : mode;
}
// Slash-command parsing — opencode also expands command files, but if the
// user types the literal slash command we still want to flip the flag.
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
const cmd = parts[0];
const arg = parts[1] || '';
if (cmd === '/caveman-commit') return 'commit';
if (cmd === '/caveman-review') return 'review';
if (cmd === '/caveman-compress') return 'compress';
if (cmd === '/caveman') {
if (!arg) return getDefaultMode();
if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
if (arg === 'wenyan-full') return 'wenyan';
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
// Unknown arg — leave flag alone. No silent overwrite.
return null;
}
}
return null;
}
function applyModeChange(mode) {
if (!mode) return;
if (mode === 'off') {
function applyModeChange(change) {
if (!change) return;
if (change.action === 'clear') {
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
return;
}
safeWriteFlag(flagPath, mode);
if (change.action === 'set' && change.mode) {
safeWriteFlag(flagPath, change.mode);
}
}
// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15)
@@ -203,11 +154,14 @@ export const CavemanPlugin = async (_ctx) => {
// mode toggles. opencode fires chat.message with (input, output) where
// output.parts is the array of message parts; text parts carry .text.
// Return value is ignored — state changes happen via the flag file.
// expandedTpl: opencode replaces a typed slash command with its command
// file's prose before this hook sees it. unwrapQuotes: the non-interactive
// `run` path delivers the message wrapped in literal quote characters.
'chat.message': async (_input, output) => {
if (!output || !output.parts) return;
for (const part of output.parts) {
if (part && part.type === 'text' && part.text) {
const change = parseModeChange(part.text);
const change = parseModeChange(part.text, { getDefaultMode, expandedTpl: true, unwrapQuotes: true });
if (change) applyModeChange(change);
}
}
+2 -2
View File
@@ -36,11 +36,11 @@ const SENTINEL = 'Respond terse like smart caveman';
// OpenClaw is a global workspace tool (not per-repo) and needs two write
// targets — a skill folder + a SOUL.md bootstrap block. The shared helper
// lives at bin/lib/openclaw.js; we require it lazily so caveman-init.js
// lives at cli/lib/openclaw.js; we require it lazily so caveman-init.js
// keeps working when run standalone (curl|node) without the helper on disk.
function loadOpenclawHelper() {
try {
return require(path.join(__dirname, '..', '..', 'bin', 'lib', 'openclaw.js'));
return require(path.join(__dirname, '..', '..', 'cli', 'lib', 'openclaw.js'));
} catch (_) { return null; }
}
+12 -1
View File
@@ -9,7 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
function freshTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-'));
@@ -39,6 +39,17 @@ test('dry-run --only claude prints plan and writes nothing', () => {
assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false);
});
test('dry-run --only gemini passes --consent (issue #676 — avoids the confirmation-prompt hang)', () => {
const cfg = freshTmpDir();
// --only forces installGemini to run regardless of whether `gemini` is
// actually on PATH — safe to assert against on any CI runner.
const r = spawnSync('node', [INSTALLER,
'--dry-run', '--only', 'gemini', '--non-interactive', '--config-dir', cfg,
], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
assert.equal(r.status, 0);
assert.match(r.stdout, /would run: gemini extensions install https:\/\/github\.com\/\S+ --consent/);
});
test('dry-run --uninstall does not delete files', () => {
const cfg = freshTmpDir();
// Seed a fake installation
+154 -9
View File
@@ -35,9 +35,9 @@ import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
const requireCjs = createRequire(import.meta.url);
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'settings.js'));
function freshTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-freshinstall-'));
@@ -71,7 +71,7 @@ function runInstaller(args, configDir, extraEnv = {}) {
}
function hasClaudeCli() {
// We can't import bin/install.js's hasCmd directly (CJS, not exported), but
// We can't import cli/install.js's hasCmd directly (CJS, not exported), but
// a plain `command -v` / `where` shell-out is equivalent for this purpose.
if (process.platform === 'win32') {
return spawnSync('where', ['claude'], { stdio: 'ignore' }).status === 0;
@@ -202,9 +202,75 @@ test('uninstall strips caveman hooks but preserves user-authored ones (skipped w
}
});
// ── Test: uninstall removes stale per-session state, keeps lifetime history (#635) ──
// Pre-fix, uninstall only ever removed `.caveman-active`, leaving
// `.caveman-active.prev`, `.caveman-mode-log.jsonl`, `.caveman-statusline-suffix`,
// and `.caveman-nudge-shown` behind forever. `.caveman-history.jsonl` is the
// user's lifetime savings ledger and must be kept (with a "kept" note), not
// treated as stale state. Runs unconditionally — no `claude` CLI needed, this
// only exercises the file-cleanup part of uninstall.
test('uninstall removes stale per-session state files but keeps lifetime history', () => {
const dir = freshTmpDir();
try {
const staleFiles = [
'.caveman-active',
'.caveman-active.prev',
'.caveman-mode-log.jsonl',
'.caveman-statusline-suffix',
'.caveman-nudge-shown',
];
for (const f of staleFiles) fs.writeFileSync(path.join(dir, f), 'x');
const historyPath = path.join(dir, '.caveman-history.jsonl');
fs.writeFileSync(historyPath, '{"ts":1}\n');
const cleanPath = pathWithout(['claude', 'gemini']);
const r = runInstaller(['--uninstall'], dir, { PATH: cleanPath });
assert.notEqual(r.status, 2, `uninstall argv error: ${r.stderr}`);
for (const f of staleFiles) {
assert.equal(fs.existsSync(path.join(dir, f)), false, `${f} should be removed by uninstall`);
}
assert.ok(fs.existsSync(historyPath), '.caveman-history.jsonl must survive uninstall');
assert.match(r.stdout, /kept .*caveman-history\.jsonl.*lifetime history/,
'uninstall must explain why lifetime history was kept');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ── Test: uninstall --dry-run is honest (doesn't claim removal, doesn't delete) ──
test('uninstall --dry-run reports "would remove" and deletes nothing', () => {
const dir = freshTmpDir();
const hooksDir = path.join(dir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
try {
fs.writeFileSync(path.join(dir, '.caveman-active'), 'full');
fs.writeFileSync(path.join(dir, '.caveman-mode-log.jsonl'), '{}\n');
fs.writeFileSync(path.join(hooksDir, 'caveman-activate.js'), '// stub\n');
const cleanPath = pathWithout(['claude', 'gemini']);
const r = runInstaller(['--uninstall', '--dry-run'], dir, { PATH: cleanPath });
assert.notEqual(r.status, 2, `uninstall dry-run argv error: ${r.stderr}`);
// Nothing actually deleted under --dry-run.
assert.ok(fs.existsSync(path.join(dir, '.caveman-active')), 'dry-run must not delete .caveman-active');
assert.ok(fs.existsSync(path.join(dir, '.caveman-mode-log.jsonl')), 'dry-run must not delete .caveman-mode-log.jsonl');
assert.ok(fs.existsSync(path.join(hooksDir, 'caveman-activate.js')), 'dry-run must not delete hook files');
// Every per-file line for our files says "would remove", never a bare "removed".
const lines = r.stdout.split('\n').filter(l => /caveman-active\b|caveman-mode-log\.jsonl|caveman-activate\.js/.test(l));
assert.ok(lines.length > 0, 'expected at least one reported line for the seeded files');
for (const line of lines) {
assert.match(line, /would remove/, `dry-run line must say "would remove", got: ${line}`);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ── Test: settings.json with JSONC comments doesn't crash (#249) ───────────
// Regression guard: the installer used to crash here because JSON.parse can't
// eat // or /* */. bin/lib/settings.js now strips them before merging.
// eat // or /* */. cli/lib/settings.js now strips them before merging.
test('install tolerates JSONC settings.json (comments + trailing commas)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
const dir = freshTmpDir();
try {
@@ -263,7 +329,7 @@ test('openclaw install writes skill folder + SOUL.md bootstrap', () => {
assert.match(skillRaw, /\nalways:\s*true/, 'skill missing always: true frontmatter');
// Body after the merged frontmatter must match the source body.
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const srcRaw = fs.readFileSync(SKILL_BODY_SRC, 'utf8');
const srcBody = helper.splitFrontmatter(srcRaw).body;
const installedBody = helper.splitFrontmatter(skillRaw).body;
@@ -281,6 +347,64 @@ test('openclaw install writes skill folder + SOUL.md bootstrap', () => {
}
});
test('openclaw install stamps the skill version from PINNED_REF, not a hardcoded 1.0.0', () => {
const dir = freshTmpDir();
const ws = path.join(dir, 'ws');
fs.mkdirSync(ws);
try {
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
encoding: 'utf8',
});
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
const { OPENCLAW_SKILL_VERSION } = requireCjs(INSTALLER);
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
assert.match(skillRaw, new RegExp(`\\nversion:\\s*${OPENCLAW_SKILL_VERSION.replace(/\./g, '\\.')}\\b`),
`expected version: ${OPENCLAW_SKILL_VERSION} threaded from PINNED_REF`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('openclaw --no-always skips `always: true` frontmatter and the SOUL.md bootstrap append', () => {
const dir = freshTmpDir();
const ws = path.join(dir, 'ws');
fs.mkdirSync(ws);
try {
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--no-always', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
encoding: 'utf8',
});
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
assert.doesNotMatch(skillRaw, /\nalways:\s*true/, '--no-always must omit the always: true frontmatter key');
assert.match(skillRaw, /\nversion:\s*\d+\.\d+\.\d+/, '--no-always must still stamp a version key');
assert.equal(fs.existsSync(path.join(ws, 'SOUL.md')), false, '--no-always must not create/append SOUL.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('openclaw default (no flag) behavior is unchanged: always: true + SOUL.md still written', () => {
const dir = freshTmpDir();
const ws = path.join(dir, 'ws');
fs.mkdirSync(ws);
try {
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
encoding: 'utf8',
});
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
assert.match(skillRaw, /\nalways:\s*true/, 'default install must still set always: true');
assert.ok(fs.existsSync(path.join(ws, 'SOUL.md')), 'default install must still write SOUL.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('openclaw install is idempotent: skill frontmatter not double-prepended, SOUL.md has one marker block', () => {
const dir = freshTmpDir();
const ws = path.join(dir, 'ws');
@@ -439,12 +563,33 @@ test('opencode: --force on legacy AGENTS.md preserves user content and takes a b
}
});
// ── Tests: mergeOpenclawFrontmatter version/always opts (lib-level, direct) ──
test('mergeOpenclawFrontmatter: custom version opt overrides the SKILL_VERSION fallback', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n', { version: '1.9.1' });
assert.match(out, /\nversion: 1\.9\.1\n/);
assert.match(out, /\nalways: true\n/, 'always defaults to true when opts.always is omitted');
});
test('mergeOpenclawFrontmatter: no opts falls back to SKILL_VERSION (default behavior unchanged)', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n');
assert.match(out, new RegExp(`\\nversion: ${helper.SKILL_VERSION.replace(/\./g, '\\.')}\\n`));
});
test('mergeOpenclawFrontmatter: always:false omits the always key entirely', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n', { version: '2.0.0', always: false });
assert.match(out, /\nversion: 2\.0\.0\n/);
assert.doesNotMatch(out, /always:/);
});
// ── Tests: SOUL.md marker damage tolerance (#596) ──────────────────────────
// A stray/truncated marker used to chain into data loss: append added a
// second block, then strip cut from the FIRST begin to the FIRST end —
// spanning all user content in between. These drive the helper directly.
test('openclaw: truncated begin marker does not eat user content (issue #596 chain)', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const dir = freshTmpDir();
const soul = path.join(dir, 'SOUL.md');
try {
@@ -470,7 +615,7 @@ test('openclaw: truncated begin marker does not eat user content (issue #596 cha
});
test('openclaw: strip removes multiple blocks pairwise, keeping user content between them', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const dir = freshTmpDir();
const soul = path.join(dir, 'SOUL.md');
try {
@@ -488,7 +633,7 @@ test('openclaw: strip removes multiple blocks pairwise, keeping user content bet
});
test('openclaw: orphan end marker stripped without touching content', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const dir = freshTmpDir();
const soul = path.join(dir, 'SOUL.md');
try {
@@ -505,7 +650,7 @@ test('openclaw: orphan end marker stripped without touching content', () => {
});
test('openclaw: append on a well-formed block stays a no-op', () => {
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
const dir = freshTmpDir();
const soul = path.join(dir, 'SOUL.md');
try {
+1 -1
View File
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
const SKILLS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];
+1 -1
View File
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const requireCjs = createRequire(import.meta.url);
const { stripOpencodeAgentTools } = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'opencode-agent.js'));
const { stripOpencodeAgentTools } = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'opencode-agent.js'));
const SHIPPED_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md'];
+2 -2
View File
@@ -16,9 +16,9 @@ import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
const requireCjs = createRequire(import.meta.url);
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'settings.js'));
const IS_WIN = process.platform === 'win32';
+27 -2
View File
@@ -1,4 +1,4 @@
// Unit tests for the argv parser embedded in bin/install.js.
// Unit tests for the argv parser embedded in cli/install.js.
// We don't import parseArgs (it's not exported) — instead we shell out to the
// installer with --help / --list / unknown flags and assert the framing.
// For deeper coverage of flag-resolution semantics, exec --dry-run --list and
@@ -9,9 +9,12 @@ import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
const requireCjs = createRequire(import.meta.url);
const { winQuoteIfNeeded } = requireCjs(INSTALLER);
function run(...args) {
return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' });
@@ -164,6 +167,28 @@ test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', ()
assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/);
});
test('winQuoteIfNeeded leaves a plain argument untouched', () => {
assert.equal(winQuoteIfNeeded('claude'), 'claude');
assert.equal(winQuoteIfNeeded('/tmp/plain-path'), '/tmp/plain-path');
});
test('winQuoteIfNeeded quotes whitespace and embedded quotes (pre-existing behavior)', () => {
assert.equal(winQuoteIfNeeded('has space'), '"has space"');
assert.equal(winQuoteIfNeeded(''), '""');
});
test('winQuoteIfNeeded quotes cmd.exe metacharacters (Windows command-injection fix)', () => {
// Pre-fix, the trigger regex only matched /[\s"]/ — none of these contain
// whitespace or a quote, so they reached cmd.exe (via spawnXplat's
// `shell: true`) completely unquoted. An attacker-influenced arg like a
// --with-mcp-shrink value or --with-init cwd containing one of these could
// chain a second command (e.g. `foo & calc.exe`).
for (const ch of ['&', '|', '^', '<', '>', '%', '(', ')']) {
const arg = `foo${ch}bar`;
assert.equal(winQuoteIfNeeded(arg), `"${arg}"`, `metacharacter ${JSON.stringify(ch)} must trigger quoting`);
}
});
test('--help discloses --config-dir scope', () => {
const r = run('--help');
assert.equal(r.status, 0);
+14 -2
View File
@@ -1,4 +1,4 @@
// Unit tests for bin/lib/settings.js — the JSONC-tolerant settings helper.
// Unit tests for cli/lib/settings.js — the JSONC-tolerant settings helper.
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -8,7 +8,7 @@ import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const SETTINGS = require('../../bin/lib/settings.js');
const SETTINGS = require('../../cli/lib/settings.js');
function tmpFile(name, contents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-settings-'));
@@ -192,6 +192,18 @@ test('removeCavemanHooks removes the Windows statusline-stats wiring (caveman-st
assert.equal(s.hooks, undefined);
});
test('rewriteLegacyManagedHookCommands tolerates malformed hook event values without throwing', () => {
// installHooks calls this BEFORE validateHookFields, so a hook event value
// that survives JSONC parse as an object/string (not an array) must not
// throw here — pre-fix, `for (const entry of settings.hooks[ev])` blew up
// with a TypeError on a non-iterable object, killing the installer mid-run.
// Mirror of the guard removeCavemanHooks already has.
const s = { hooks: { SessionStart: { not: 'an array' }, UserPromptSubmit: 'oops' } };
let n;
assert.doesNotThrow(() => { n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node'); });
assert.equal(n, 0);
});
test('rewriteLegacyManagedHookCommands rewrites bare-node managed scripts', () => {
const s = {
hooks: {
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env node
// Tests for the shared mode-change parser (#602), src/hooks/caveman-parse.js.
// caveman-mode-tracker.js and the opencode plugin both consume this module —
// these tests exercise it directly (unit-level) and also check that its
// verdicts line up with what the real tracker.js hook does for the same
// prompts (parity), so the two callers can't silently drift apart again.
//
// Run: node tests/test_caveman_parse.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
const { spawnSync } = require('child_process');
const { parseModeChange, INDEPENDENT_MODES } = require('../src/hooks/caveman-parse');
const HOOK_PATH = path.resolve(__dirname, '..', 'src', 'hooks', 'caveman-mode-tracker.js');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.error(`${name}`);
console.error(` ${e.message}`);
}
}
console.log('caveman-parse (shared mode-change parser) tests\n');
const defaultFull = { getDefaultMode: () => 'full' };
const defaultOff = { getDefaultMode: () => 'off' };
// ---------- basic unit coverage ----------
test('empty/whitespace prompt is a no-op', () => {
assert.strictEqual(parseModeChange('', defaultFull), null);
assert.strictEqual(parseModeChange(' ', defaultFull), null);
});
test('slash level switch', () => {
assert.deepStrictEqual(parseModeChange('/caveman ultra', defaultFull), { action: 'set', mode: 'ultra' });
});
test('bare /caveman activates at the configured default', () => {
assert.deepStrictEqual(parseModeChange('/caveman', defaultFull), { action: 'set', mode: 'full' });
});
test('bare /caveman with an off default clears instead of setting mode "off"', () => {
assert.deepStrictEqual(parseModeChange('/caveman', defaultOff), { action: 'clear' });
});
test('/caveman off|stop|disable all clear', () => {
assert.deepStrictEqual(parseModeChange('/caveman off', defaultFull), { action: 'clear' });
assert.deepStrictEqual(parseModeChange('/caveman stop', defaultFull), { action: 'clear' });
assert.deepStrictEqual(parseModeChange('/caveman disable', defaultFull), { action: 'clear' });
});
test('wenyan-full alias stores as "wenyan"', () => {
assert.deepStrictEqual(parseModeChange('/caveman wenyan-full', defaultFull), { action: 'set', mode: 'wenyan' });
});
test('bogus level returns null — never falls through to the default', () => {
assert.strictEqual(parseModeChange('/caveman not-a-real-level', defaultFull), null);
});
test('independent modes are not reachable via /caveman <arg>', () => {
assert.strictEqual(parseModeChange('/caveman commit', defaultFull), null);
});
test('/caveman-commit, /caveman-review, /caveman-compress set independent modes', () => {
assert.deepStrictEqual(parseModeChange('/caveman-commit', defaultFull), { action: 'set', mode: 'commit' });
assert.deepStrictEqual(parseModeChange('/caveman-review', defaultFull), { action: 'set', mode: 'review' });
assert.deepStrictEqual(parseModeChange('/caveman-compress', defaultFull), { action: 'set', mode: 'compress' });
});
test('namespaced /caveman:caveman-* variants are recognized', () => {
assert.deepStrictEqual(parseModeChange('/caveman:caveman-commit', defaultFull), { action: 'set', mode: 'commit' });
assert.deepStrictEqual(parseModeChange('/caveman:caveman-review', defaultFull), { action: 'set', mode: 'review' });
assert.deepStrictEqual(parseModeChange('/caveman:caveman', defaultFull), { action: 'set', mode: 'full' });
});
test('natural-language activation', () => {
assert.deepStrictEqual(parseModeChange('activate caveman', defaultFull), { action: 'set', mode: 'full' });
assert.deepStrictEqual(parseModeChange('talk like a caveman', defaultFull), { action: 'set', mode: 'full' });
});
test('brevity triggers activate', () => {
assert.deepStrictEqual(parseModeChange('be brief', defaultFull), { action: 'set', mode: 'full' });
assert.deepStrictEqual(parseModeChange('fewer tokens please', defaultFull), { action: 'set', mode: 'full' });
});
test('scoped brevity ("be brief in the summary") does not activate', () => {
assert.strictEqual(parseModeChange('be brief in the summary section', defaultFull), null);
});
test('questions about caveman do not activate', () => {
assert.strictEqual(parseModeChange('what is caveman mode?', defaultFull), null);
});
test('natural-language deactivation', () => {
assert.deepStrictEqual(parseModeChange('turn caveman mode off', defaultFull), { action: 'clear' });
assert.deepStrictEqual(parseModeChange('normal mode', defaultFull), { action: 'clear' });
});
test('vim "normal mode" (no caveman context) does not deactivate', () => {
assert.strictEqual(parseModeChange('how do I exit vim normal mode', defaultFull), null);
});
test('INDEPENDENT_MODES is exported and matches the known set', () => {
assert.deepStrictEqual([...INDEPENDENT_MODES].sort(), ['commit', 'compress', 'review']);
});
// ---------- skipNaturalLanguage (foreign command envelopes, #537) ----------
test('skipNaturalLanguage suppresses activation/deactivation matching entirely', () => {
assert.strictEqual(
parseModeChange('please activate caveman mode now', { ...defaultFull, skipNaturalLanguage: true }),
null
);
assert.strictEqual(
parseModeChange('stop caveman', { ...defaultFull, skipNaturalLanguage: true }),
null
);
});
test('skipNaturalLanguage still lets literal slash commands through', () => {
assert.deepStrictEqual(
parseModeChange('/caveman ultra', { ...defaultFull, skipNaturalLanguage: true }),
{ action: 'set', mode: 'ultra' }
);
});
// ---------- unwrapQuotes (opencode `run` path) ----------
test('unwrapQuotes strips a symmetric quote wrapper before matching', () => {
assert.deepStrictEqual(
parseModeChange('"/caveman lite"', { ...defaultFull, unwrapQuotes: true }),
{ action: 'set', mode: 'lite' }
);
});
test('without unwrapQuotes, a quoted command does not match', () => {
assert.strictEqual(parseModeChange('"/caveman lite"', defaultFull), null);
});
// ---------- expandedTpl (opencode's expanded command-template bodies) ----------
test('expandedTpl recognizes the generic "/caveman <level>" template', () => {
assert.deepStrictEqual(
parseModeChange('Activate caveman mode: ultra', { ...defaultFull, expandedTpl: true }),
{ action: 'set', mode: 'ultra' }
);
});
test('expandedTpl: empty level (bare "/caveman", multi-line template head) uses the default', () => {
// The real commands/caveman.md template puts `Activate caveman mode:
// $ARGUMENTS` on its own line, followed by a blank line and then fixed
// boilerplate ("If no level given, use full. If \"off\", deactivate.").
// With $ARGUMENTS empty, whitespace-collapse used to merge that boilerplate
// directly onto the same line as the (empty) argument, so the word "if"
// (from "If no level given ...") was captured as the level and rejected as
// bogus — a bare `/caveman` in opencode silently never activated.
// Regression guard for that (matches the shape exercised by
// tests/installer/opencode.test.mjs's real-hooks test).
const templateNoArgs =
'Activate caveman mode: \n\n' +
'If no level given, use full. If "off", deactivate.';
assert.deepStrictEqual(
parseModeChange(templateNoArgs, { ...defaultFull, expandedTpl: true }),
{ action: 'set', mode: 'full' }
);
});
test('expandedTpl: bogus level in the template returns null, not the default (#602 drift)', () => {
assert.strictEqual(
parseModeChange('Activate caveman mode: not-a-real-level', { ...defaultFull, expandedTpl: true }),
null
);
});
test('expandedTpl recognizes the independent-mode command templates (#602 drift)', () => {
assert.deepStrictEqual(
parseModeChange('Generate a commit message for the current staged changes.', { ...defaultFull, expandedTpl: true }),
{ action: 'set', mode: 'commit' }
);
assert.deepStrictEqual(
parseModeChange('Review the current diff (or files: ).', { ...defaultFull, expandedTpl: true }),
{ action: 'set', mode: 'review' }
);
assert.deepStrictEqual(
parseModeChange('Compress the file at: notes.md', { ...defaultFull, expandedTpl: true }),
{ action: 'set', mode: 'compress' }
);
});
test('without expandedTpl, template bodies are inert plain text', () => {
assert.strictEqual(
parseModeChange('Generate a commit message for the current staged changes.', defaultFull),
null
);
});
// ---------- parity with the real tracker hook ----------
// The tracker collapses whitespace/case and applies the same option set this
// module expects (getDefaultMode, skipNaturalLanguage). For a representative
// set of raw prompts, verify the flag-file outcome the tracker produces
// matches what parseModeChange's verdict implies — proving the two stay in
// sync rather than just "both look right in isolation".
function runTracker(prompt, presetFlag) {
const cfg = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-parse-parity-'));
try {
if (presetFlag) fs.writeFileSync(path.join(cfg, '.caveman-active'), presetFlag);
spawnSync(process.execPath, [HOOK_PATH], {
input: JSON.stringify({ prompt }),
env: { ...process.env, CLAUDE_CONFIG_DIR: cfg },
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf8',
});
const flagPath = path.join(cfg, '.caveman-active');
return fs.existsSync(flagPath) ? fs.readFileSync(flagPath, 'utf8') : null;
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
}
const parityCases = [
{ prompt: '/caveman ultra', preset: null },
{ prompt: '/caveman off', preset: 'full' },
{ prompt: '/caveman not-a-real-level', preset: 'ultra' },
{ prompt: 'be brief', preset: null },
{ prompt: 'activate caveman', preset: null },
{ prompt: 'stop caveman', preset: 'full' },
{ prompt: 'what is caveman mode?', preset: null },
];
for (const { prompt, preset } of parityCases) {
test(`parity: "${prompt}" (preset=${preset}) matches shared-parser verdict`, () => {
const normalized = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
const verdict = parseModeChange(normalized, { getDefaultMode: () => 'full' });
const expected =
verdict === null ? (preset || null) :
verdict.action === 'clear' ? null :
verdict.mode;
assert.strictEqual(runTracker(prompt, preset), expected);
});
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+169 -6
View File
@@ -95,7 +95,7 @@ test('reports no-session when no .jsonl exists', (tmp) => {
assert.match(err.stderr, /no Claude Code session found/);
});
test('mode tracker handles /caveman-stats with decision block', (tmp) => {
test('mode tracker delivers /caveman-stats via additionalContext', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100 } } },
]);
@@ -107,9 +107,9 @@ test('mode tracker handles /caveman-stats with decision block', (tmp) => {
input: JSON.stringify({ prompt: '/caveman-stats', transcript_path: sess }),
});
const parsed = JSON.parse(out);
assert.strictEqual(parsed.decision, 'block');
assert.match(parsed.reason, /Caveman Stats/);
assert.match(parsed.reason, /Output tokens:\s+100/);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.match(parsed.hookSpecificOutput.additionalContext, /Caveman Stats/);
assert.match(parsed.hookSpecificOutput.additionalContext, /Output tokens:\s+100/);
});
test('mode tracker preserves caveman flag when /caveman-stats fires', (tmp) => {
@@ -221,6 +221,7 @@ test('appends to lifetime history on each run', (tmp) => {
const entry = JSON.parse(lines[0]);
assert.strictEqual(entry.session_id, 's');
assert.strictEqual(entry.output_tokens, 350);
assert.strictEqual(entry.turns, 1);
assert.strictEqual(entry.est_saved_tokens, 650);
assert.strictEqual(entry.mode, 'full');
assert.strictEqual(entry.model, 'claude-sonnet-4-7');
@@ -456,8 +457,8 @@ test('mode tracker forwards --share to stats script', (tmp) => {
input: JSON.stringify({ prompt: '/caveman-stats --share', transcript_path: sess }),
});
const parsed = JSON.parse(out);
assert.strictEqual(parsed.decision, 'block');
assert.match(parsed.reason, /^🪨 Saved 650 output tokens/);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.match(parsed.hookSpecificOutput.additionalContext, /🪨 Saved 650 output tokens/);
});
// ── Output-reduction share (never a "usage"/"budget" claim) ────────────────
@@ -633,5 +634,167 @@ test('excludes tokens that predate a mid-session flag write with no log (#601)',
assert.doesNotMatch(out, /Est\. without caveman/);
});
// ── Rule-overhead + net (#145/#677) ────────────────────────────────────────
// Gross output savings alone can never reveal the net-negative regime —
// docs/HONEST-NUMBERS.md admits caveman's rules cost ~1-1.5k input tokens
// every turn. These lines subtract that estimated cost from the estimated
// savings so a terse workload doesn't look like a win when it isn't one.
test('session shows a positive net when savings clear the rule overhead', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 1500 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// 1500/0.35 = 4286 (rounded), saved 2786; overhead 1250x1 turn; net = +1536.
assert.match(out, /Est\. rule overhead:\s+1,250 \(input, ~1,250\/turn over 1 turn\)/);
assert.match(out, /Est\. net:\s+\+1,536 \(net saving after rule overhead\)/);
});
test('session shows a NEGATIVE net and tells the user to consider turning caveman off (#145)', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// 100/0.35 = 286 (rounded), saved 186; overhead 1250; net = -1064.
assert.match(out, /Est\. net:\s+-1,064/);
assert.match(out, /caveman cost more than it saved for this workload/);
assert.match(out, /consider turning it off/);
});
test('CAVEMAN_RULE_OVERHEAD_TOKENS overrides the per-turn overhead estimate', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 1500 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_RULE_OVERHEAD_TOKENS: '500' },
});
// overhead 500x1 turn; net = 2786 - 500 = +2286.
assert.match(out, /Est\. rule overhead:\s+500 \(input, ~500\/turn over 1 turn\)/);
assert.match(out, /Est\. net:\s+\+2,286/);
});
test('deriveNet and ruleOverheadPerTurn validate a positive integer, falling back otherwise', () => {
const { deriveNet, ruleOverheadPerTurn } = require(STATS);
const saved = process.env.CAVEMAN_RULE_OVERHEAD_TOKENS;
try {
delete process.env.CAVEMAN_RULE_OVERHEAD_TOKENS;
assert.strictEqual(ruleOverheadPerTurn(), 1250);
assert.deepStrictEqual(deriveNet({ estSavedTokens: 2786, turns: 1 }), { overheadTokens: 1250, netTokens: 1536 });
process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = '500';
assert.strictEqual(ruleOverheadPerTurn(), 500);
// Invalid overrides (non-numeric, zero, negative, non-integer) all fall
// back to the default rather than produce a nonsensical overhead.
process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = 'garbage';
assert.strictEqual(ruleOverheadPerTurn(), 1250);
process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = '0';
assert.strictEqual(ruleOverheadPerTurn(), 1250);
process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = '-100';
assert.strictEqual(ruleOverheadPerTurn(), 1250);
process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = '12.5';
assert.strictEqual(ruleOverheadPerTurn(), 1250);
} finally {
if (saved === undefined) delete process.env.CAVEMAN_RULE_OVERHEAD_TOKENS;
else process.env.CAVEMAN_RULE_OVERHEAD_TOKENS = saved;
}
});
test('does not fabricate a net when the savings span is unattributed (no guessing)', (tmp) => {
const now = Date.now();
const sess = makeSession(tmp, [
{ type: 'assistant', timestamp: new Date(now - 60 * 60_000).toISOString(), message: { usage: { output_tokens: 350 } } },
]);
const claudeDir = path.join(tmp, '.claude');
// Flag written now, no transition log → mode during the message is unknown.
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
assert.match(out, /unattributed:\s+350 tokens/);
assert.doesNotMatch(out, /Est\. net:/); // no attributed savings basis → no net claim
});
test('does not fabricate a net when mode has no benchmark estimate', (tmp) => {
const sess = makeSession(tmp, [
{ type: 'assistant', message: { usage: { output_tokens: 100 } } },
]);
const claudeDir = path.join(tmp, '.claude');
fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'ultra');
const out = execFileSync(process.execPath, [STATS, '--session-file', sess], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
assert.match(out, /No savings estimate for 'ultra' mode/);
assert.doesNotMatch(out, /Est\. net:/);
});
test('lifetime view nets aggregated turns against aggregated savings', (tmp) => {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const histPath = path.join(claudeDir, '.caveman-history.jsonl');
fs.writeFileSync(histPath, [
{ ts: 1000, session_id: 'a', mode: 'full', output_tokens: 1500, est_saved_tokens: 2786, est_saved_usd: 0, turns: 1 },
{ ts: 2000, session_id: 'b', mode: 'full', output_tokens: 100, est_saved_tokens: 186, est_saved_usd: 0, turns: 1 },
].map(o => JSON.stringify(o)).join('\n') + '\n');
const out = execFileSync(process.execPath, [STATS, '--all'], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// saved 2972, overhead 1250x2 turns = 2500, net = +472.
assert.match(out, /Est\. tokens saved:\s+2,972/);
assert.match(out, /Est\. rule overhead:\s+2,500 \(input, ~1,250\/turn over 2 turns\)/);
assert.match(out, /Est\. net:\s+\+472/);
});
test('lifetime view omits net for legacy history rows that never logged turns', (tmp) => {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, '.caveman-history.jsonl'),
JSON.stringify({ ts: 1000, session_id: 'a', mode: 'full', output_tokens: 350, est_saved_tokens: 650, est_saved_usd: 0 }) + '\n');
const out = execFileSync(process.execPath, [STATS, '--all'], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// Gross total still reports (unchanged, pre-existing behavior)...
assert.match(out, /Est\. tokens saved:\s+650/);
// ...but net is omitted rather than computed against someone else's turns.
assert.doesNotMatch(out, /Est\. net:/);
});
test('lifetime view excludes legacy rows from net even when mixed with rows that logged turns', (tmp) => {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, '.caveman-history.jsonl'), [
// Legacy row: no turns field — must not contribute to net in either direction.
{ ts: 1000, session_id: 'legacy', mode: 'full', output_tokens: 350, est_saved_tokens: 650, est_saved_usd: 0 },
{ ts: 2000, session_id: 'new', mode: 'full', output_tokens: 1500, est_saved_tokens: 2786, est_saved_usd: 0, turns: 1 },
].map(o => JSON.stringify(o)).join('\n') + '\n');
const out = execFileSync(process.execPath, [STATS, '--all'], {
encoding: 'utf8',
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir },
});
// Gross total includes both rows: 650 + 2786 = 3436.
assert.match(out, /Est\. tokens saved:\s+3,436/);
// Net only nets the 'new' row's 2786 saved against its 1 logged turn —
// NOT 3436 against 1 turn, which would overstate the net.
assert.match(out, /Est\. rule overhead:\s+1,250 \(input, ~1,250\/turn over 1 turn\)/);
assert.match(out, /Est\. net:\s+\+1,536/);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
+117 -1
View File
@@ -9,6 +9,7 @@ bytes is detected before the input is overwritten.
"""
import os
import stat
import sys
import tempfile
import unittest
@@ -24,7 +25,7 @@ from scripts import compress as compress_mod # noqa: E402
class CompressSafetyTests(unittest.TestCase):
def _file_with(self, dirpath: Path, text: str) -> Path:
path = dirpath / "task.md"
path.write_text(text)
path.write_text(text, encoding="utf-8")
return path
def test_empty_input_refused(self):
@@ -88,6 +89,121 @@ class CompressSafetyTests(unittest.TestCase):
self.assertEqual(backup.read_text(), original)
self.assertFalse((Path(tmp) / "task.original.md").exists())
def test_utf8_roundtrip_survives_compression(self):
# Path.read_text() without encoding= would decode with the system
# locale codec (cp1252/cp949 on Windows) and could silently mangle
# non-ASCII bytes. Read raw bytes and decode strictly as UTF-8 so the
# assertion is locale-independent (issue #686).
with tempfile.TemporaryDirectory() as tmp, \
tempfile.TemporaryDirectory() as data_home, \
mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
original = "# Heading\n\nCafé, 中文, and an arrow → here.\n"
compressed = "# Heading\n\nCafé 中文 arrow → here.\n"
path = self._file_with(Path(tmp), original)
with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \
mock.patch.object(compress_mod, "validate") as v:
v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[])
ok = compress_mod.compress_file(path)
self.assertTrue(ok)
self.assertEqual(path.read_bytes().decode("utf-8"), compressed)
backup = compress_mod.backup_dir_for(path.resolve()) / "task.original.md"
self.assertEqual(backup.read_bytes().decode("utf-8"), original)
def test_write_text_atomic_leaves_destination_untouched_on_encode_failure(self):
# Direct unit test of the atomic-write primitive: an encode failure
# partway through must not truncate the destination or leave a *.tmp
# file behind (issue #655).
class ExplodingStr(str):
def encode(self, *args, **kwargs):
raise UnicodeEncodeError("utf-8", self, 0, 1, "forced failure")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "task.md"
path.write_text("original content", encoding="utf-8")
with self.assertRaises(UnicodeEncodeError):
compress_mod.write_text_atomic(path, ExplodingStr("new content"))
self.assertEqual(path.read_text(encoding="utf-8"), "original content")
self.assertEqual(list(Path(tmp).glob("*.tmp")), [])
def test_forced_primary_write_failure_leaves_original_and_backup_intact(self):
# Same failure, exercised through the full compress_file pipeline:
# the backup must already exist and be intact, the target must be
# untouched, and no *.tmp litter must remain in either directory.
with tempfile.TemporaryDirectory() as tmp, \
tempfile.TemporaryDirectory() as data_home, \
mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
original = "# Heading\n\nProse to compress.\n"
compressed = "# Heading\n\nProse.\n"
path = self._file_with(Path(tmp), original)
target = path.resolve()
real_write_text_atomic = compress_mod.write_text_atomic
def flaky_write(write_path, text):
if write_path == target:
raise UnicodeEncodeError("utf-8", text, 0, 1, "forced failure")
return real_write_text_atomic(write_path, text)
with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \
mock.patch.object(compress_mod, "validate") as v, \
mock.patch.object(compress_mod, "write_text_atomic", side_effect=flaky_write):
v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[])
with self.assertRaises(UnicodeEncodeError):
compress_mod.compress_file(path)
self.assertEqual(path.read_text(encoding="utf-8"), original)
backup_dir = compress_mod.backup_dir_for(target)
backup = backup_dir / "task.original.md"
self.assertEqual(backup.read_text(encoding="utf-8"), original)
self.assertEqual(list(Path(tmp).glob("*.tmp")), [])
self.assertEqual(list(backup_dir.glob("*.tmp")), [])
def test_permission_preserved_across_compression(self):
with tempfile.TemporaryDirectory() as tmp, \
tempfile.TemporaryDirectory() as data_home, \
mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
original = "# Heading\n\nProse to compress.\n"
compressed = "# Heading\n\nProse.\n"
path = self._file_with(Path(tmp), original)
path.chmod(0o644)
with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \
mock.patch.object(compress_mod, "validate") as v:
v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[])
ok = compress_mod.compress_file(path)
self.assertTrue(ok)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o644)
def test_retry_preamble_output_rejected_and_not_written(self):
# A fix-retry response with a prose preamble ahead of the real content
# must never reach disk — only the restore-on-failure write should
# land, and it must restore the original (issue #588).
with tempfile.TemporaryDirectory() as tmp, \
tempfile.TemporaryDirectory() as data_home, \
mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}):
original = "# Heading\n\nProse that fails validation.\n"
first_pass = "# Heading\n\nCompressed prose.\n"
preamble_fix = "Here is the fixed file:\n\n# Heading\n\nCompressed prose, fixed.\n"
path = self._file_with(Path(tmp), original)
invalid = mock.Mock(is_valid=False, errors=["some validation error"], warnings=[])
written_texts = []
real_write_target = compress_mod._write_target
def spy_write_target(target_path, text, backup_path):
written_texts.append(text)
return real_write_target(target_path, text, backup_path)
with mock.patch.object(
compress_mod, "call_claude", side_effect=[first_pass, preamble_fix]
), mock.patch.object(compress_mod, "validate", return_value=invalid), \
mock.patch.object(compress_mod, "_write_target", side_effect=spy_write_target):
ok = compress_mod.compress_file(path)
self.assertFalse(ok)
self.assertNotIn(preamble_fix, written_texts)
self.assertEqual(path.read_text(encoding="utf-8"), original)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -81,5 +81,179 @@ test('normal stdin (valid JSON + clean EOF) still exits 0', () => {
}
});
// ---------- helpers for the tests below ----------
function makeConfigDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-'));
}
function send(configDir, payload) {
return spawnSync(process.execPath, [HOOK_PATH], {
input: JSON.stringify(payload),
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf8',
});
}
function flagValue(configDir) {
const p = path.join(configDir, '.caveman-active');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
}
function envelope(name, args, newlines) {
const sep = newlines ? '\n' : '';
return (
`<command-message>${name.replace(/^\//, '')}</command-message>${sep}` +
`<command-name>${name}</command-name>${sep}` +
`<command-args>${args}</command-args>`
);
}
function makeSession(configDir, lines) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-sess-'));
const sessFile = path.join(dir, 's.jsonl');
fs.writeFileSync(sessFile, lines.map(l => JSON.stringify(l)).join('\n'));
return sessFile;
}
// ---------- #537: slash-command envelope unwrap ----------
test('envelope one-line form switches level', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const r = send(cfg, { prompt: envelope('/caveman', 'lite', false) });
assert.strictEqual(flagValue(cfg), 'lite');
assert.match(r.stdout, /CAVEMAN MODE ACTIVE \(lite\)/);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('envelope newline-separated form switches level', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/caveman', 'ultra', true) });
assert.strictEqual(flagValue(cfg), 'ultra');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('envelope "/caveman off" deactivates', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/caveman', 'off', true) });
assert.strictEqual(flagValue(cfg), null);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('foreign command envelope is left untouched (no NL misfire on its args)', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/commit', 'fix the caveman parser', true) });
assert.strictEqual(flagValue(cfg), 'full', 'foreign envelope must not touch the flag');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- bogus level must never fall through to the default ----------
test('bogus /caveman level leaves the flag unchanged', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'ultra');
send(cfg, { prompt: '/caveman not-a-real-level' });
assert.strictEqual(flagValue(cfg), 'ultra');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- brevity trigger (#602 drift also fixed in opencode) ----------
test('brevity trigger ("be brief") activates caveman at the default mode', () => {
const cfg = makeConfigDir();
try {
send(cfg, { prompt: 'be brief' });
assert.strictEqual(flagValue(cfg), 'full');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- scheduled-task guard ----------
test('scheduled-task prompt emits nothing while caveman active (control: normal prompt is reinforced)', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const scheduled = send(cfg, {
prompt: '<scheduled-task name="trigger-runner" file="/x/SKILL.md">\nAutomated run.',
});
assert.strictEqual(scheduled.status, CLEAN_EXIT);
assert.strictEqual((scheduled.stdout || '').trim(), '', 'scheduled-task run must emit no reinforcement');
assert.strictEqual(flagValue(cfg), 'full', 'scheduled-task run must not mutate the flag');
const normal = send(cfg, { prompt: 'fix the auth bug' });
assert.ok(/CAVEMAN MODE ACTIVE/.test(normal.stdout || ''), 'control prompt should be reinforced');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- #634: repo-local defaultMode "off" gates reinforcement only ----------
test('defaultMode off (via cwd-scoped repo config) suppresses reinforcement but leaves the flag alone', () => {
const cfg = makeConfigDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-repo-'));
try {
fs.writeFileSync(path.join(repoDir, '.caveman.json'), JSON.stringify({ defaultMode: 'off' }));
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const gated = send(cfg, { prompt: 'fix the auth bug', cwd: repoDir });
assert.strictEqual((gated.stdout || '').trim(), '', 'reinforcement must be suppressed');
assert.strictEqual(flagValue(cfg), 'full', 'gating must never touch the flag file');
// Control: same flag, no cwd override — reinforcement fires normally.
const ungated = send(cfg, { prompt: 'fix the auth bug' });
assert.ok(/CAVEMAN MODE ACTIVE/.test(ungated.stdout || ''));
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
// ---------- #618: stats delivery via additionalContext, not decision:block ----------
test('/caveman-stats emits hookSpecificOutput.additionalContext, not decision:block', () => {
const cfg = makeConfigDir();
try {
const sess = makeSession(cfg, [
{ type: 'assistant', message: { usage: { output_tokens: 350 } } },
]);
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const r = send(cfg, { prompt: '/caveman-stats', transcript_path: sess });
const parsed = JSON.parse(r.stdout);
assert.strictEqual(parsed.decision, undefined, 'old decision:block shape must be gone');
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.ok(
/print this stats block verbatim/i.test(parsed.hookSpecificOutput.additionalContext),
'additionalContext must instruct the model to relay the block verbatim'
);
assert.match(parsed.hookSpecificOutput.additionalContext, /Saved 650 output tokens|Caveman Stats/);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+21
View File
@@ -156,6 +156,27 @@ test('findRepoConfigPath returns null outside any repo', (tmp) => {
assert.strictEqual(findRepoConfigPath(tmp), null);
});
// ── #634: optional startDir param (backward compatible) ────────────────────
test('getDefaultMode(startDir) resolves repo config for a directory other than process.cwd()', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'off' }));
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-elsewhere-'));
try {
process.chdir(elsewhere); // process cwd has no repo config
assert.strictEqual(getDefaultMode(), 'full', 'process cwd alone should not see the other dir\'s config');
assert.strictEqual(getDefaultMode(tmp), 'off', 'startDir should resolve that directory\'s repo config');
} finally {
fs.rmSync(elsewhere, { recursive: true, force: true });
}
});
test('getDefaultMode() with no args is unchanged (defaults to process.cwd())', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
assert.strictEqual(getDefaultMode(undefined), 'lite');
});
console.log(`\n${passed} passed, ${failed} failed`);
fs.rmSync(tmpHome, { recursive: true, force: true });
process.exit(failed === 0 ? 0 : 1);
+76
View File
@@ -190,6 +190,82 @@ test('all valid modes round-trip through symlinked parent', (tmp) => {
}
});
// ---------- rename retry + guaranteed temp cleanup (#511/#578/#657) ----------
test('recovers from transient rename failures within the retry budget', (tmp) => {
const flagDir = path.join(tmp, 'claude-config');
fs.mkdirSync(flagDir, { recursive: true });
const flagPath = path.join(flagDir, '.caveman-active');
// Simulate a lock held by another process (statusline read, concurrent
// hook) that clears after two attempts — the third rename should succeed.
const realRenameSync = fs.renameSync;
let calls = 0;
fs.renameSync = (...args) => {
calls++;
if (calls < 3) {
const err = new Error('EBUSY: resource busy or locked');
err.code = 'EBUSY';
throw err;
}
return realRenameSync(...args);
};
try {
safeWriteFlag(flagPath, 'ultra');
} finally {
fs.renameSync = realRenameSync;
}
assert.strictEqual(readFlag(flagPath), 'ultra', 'flag should be written once the lock clears');
const leftovers = fs.readdirSync(flagDir).filter(n => n !== '.caveman-active');
assert.deepStrictEqual(leftovers, [], 'no temp file should remain after a successful retry');
});
test('gives up silently after 3 failed attempts and leaves no orphaned temp file', (tmp) => {
const flagDir = path.join(tmp, 'claude-config');
fs.mkdirSync(flagDir, { recursive: true });
const flagPath = path.join(flagDir, '.caveman-active');
fs.writeFileSync(flagPath, 'full');
const realRenameSync = fs.renameSync;
fs.renameSync = () => {
const err = new Error('EPERM: operation not permitted');
err.code = 'EPERM';
throw err;
};
try {
assert.doesNotThrow(() => safeWriteFlag(flagPath, 'ultra'), 'must silent-fail, never throw');
} finally {
fs.renameSync = realRenameSync;
}
assert.strictEqual(fs.readFileSync(flagPath, 'utf8'), 'full', 'original flag content untouched');
const files = fs.readdirSync(flagDir);
assert.deepStrictEqual(files, ['.caveman-active'], `temp file leaked: ${files}`);
});
test('a non-transient rename error also leaves no orphaned temp file', (tmp) => {
const flagDir = path.join(tmp, 'claude-config');
fs.mkdirSync(flagDir, { recursive: true });
const flagPath = path.join(flagDir, '.caveman-active');
const realRenameSync = fs.renameSync;
fs.renameSync = () => {
const err = new Error('ENOSPC: no space left on device');
err.code = 'ENOSPC'; // not in the transient retry list
throw err;
};
try {
assert.doesNotThrow(() => safeWriteFlag(flagPath, 'ultra'), 'silent-fail semantics must hold for any error');
} finally {
fs.renameSync = realRenameSync;
}
const files = fs.readdirSync(flagDir).filter(n => n !== '.caveman-active');
assert.deepStrictEqual(files, [], 'temp file must be cleaned up even for a non-retried error');
assert.strictEqual(fs.existsSync(flagPath), false, 'flag was never created');
});
// ---------- Source code audit ----------
test('safeWriteFlag no longer has blanket symlink parent refusal', (tmp) => {
+11
View File
@@ -41,6 +41,17 @@ More text with `inline3`.
def test_empty(self):
self.assertEqual(extract_inline_codes("no backticks here"), [])
def test_indented_fence_backtick_not_leaked_as_inline(self):
# A fence indented 1-3 spaces is valid CommonMark and already handled
# by extract_code_blocks/FENCE_OPEN_REGEX. The old column-0-anchored
# strip regex missed it, so a backtick inside the indented fence body
# leaked out and got paired with the next real inline span (issue
# from PR #619 review). Only the real trailing inline span should
# come back.
text = " ```\n `weird`\n ```\nReal `inline` span here."
result = extract_inline_codes(text)
self.assertEqual(result, ["inline"])
class TestValidateInlineCodes(unittest.TestCase):
def test_match(self):
+6 -6
View File
@@ -143,12 +143,12 @@ def verify_synced_files() -> None:
)
ensure(
(ROOT / "bin" / "install.js").exists(),
"bin/install.js missing — package.json bin entry would break npx caveman",
(ROOT / "cli" / "install.js").exists(),
"cli/install.js missing — package.json bin entry would break npx caveman",
)
ensure(
(ROOT / "bin" / "lib" / "settings.js").exists(),
"bin/lib/settings.js missing — installer would crash on JSONC settings.json",
(ROOT / "cli" / "lib" / "settings.js").exists(),
"cli/lib/settings.js missing — installer would crash on JSONC settings.json",
)
print("Synced copies, caveman.skill zip, and installer entrypoints OK")
@@ -171,8 +171,8 @@ def verify_manifests_and_syntax() -> None:
run(["node", "--check", "src/hooks/caveman-activate.js"])
run(["node", "--check", "src/hooks/caveman-mode-tracker.js"])
run(["node", "--check", "src/hooks/cavecrew-model-overrides.js"])
run(["node", "--check", "bin/install.js"])
run(["node", "--check", "bin/lib/settings.js"])
run(["node", "--check", "cli/install.js"])
run(["node", "--check", "cli/lib/settings.js"])
run(["bash", "-n", "src/hooks/install.sh"])
run(["bash", "-n", "src/hooks/uninstall.sh"])
run(["bash", "-n", "src/hooks/caveman-statusline.sh"])