mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3098342331 | ||
|
|
11ddc0c981 | ||
|
|
14d4f2e21a | ||
|
|
ec83e5bace | ||
|
|
7066cc8154 | ||
|
|
fcf7663366 | ||
|
|
e07431f127 | ||
|
|
52bbbcdd86 | ||
|
|
6566ec23d3 | ||
|
|
ed1fbb7f4c | ||
|
|
dcd51f16fe | ||
|
|
d833f4adab | ||
|
|
7693a046ee | ||
|
|
710173f965 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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&utm_medium=badge&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 22–87%), 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 22–87%), 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 ~1–1.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. ⭐
|
||||
|
||||
[](https://star-history.com/#JuliusBrussee/caveman&Date)
|
||||
[](https://star-history.com/#JuliusBrussee/caveman&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
@@ -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
@@ -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
@@ -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
@@ -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,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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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" }
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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, '..');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user