The standalone cache planner is no longer in scope here. Drop the
directory and its BSL listing, license scope note, contributor note,
and the verify_repo license-boundary entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
compress_file() wrote each un-validated candidate straight to the live
source file and validated it in place. An interruption (Ctrl-C,
SIGTERM, OOM, or a harness killing the process) between that write and
a passing validation left the live file holding un-validated or
garbage output, with the out-of-tree backup as the only recovery path.
Each candidate is now staged next to the source and validated there;
the live file is written only once a candidate passes, and left
untouched if every attempt fails.
Fixes#544
Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
subprocess.run([claude_bin, "--print"], ...) and the Anthropic SDK call in
call_claude() had no timeout, so a stalled claude CLI (dropped network, an
auth prompt with no TTY to answer it) blocked the caller forever instead of
failing into the existing retry loop. CLAUDE_CALL_TIMEOUT_SECONDS derives
from LOCK_WAIT_SECONDS / (MAX_RETRIES + 1), so the lock comment's claimed
worst-case bound is now actually enforced rather than assumed.
The ENOLCK-exclusion comment claimed the errno "means" transient kernel
lock-record exhaustion; it can also signal a permanent NFS remote-locking
failure, so the comment now says it isn't a reliable capability signal
rather than asserting a single cause.
Patch LOCK_WAIT_SECONDS/LOCK_POLL_INTERVAL down in the two new file_lock
error-path tests, matching every other lock test in this file, so a
misclassification regression fails in seconds instead of spinning out the
real 900s budget.
Moved the "checks run before the lock" comment one line down to sit above
the checks themselves rather than above the MAX_FILE_SIZE assignment, and
added the missing basic-checks step to the README pipeline diagram, which
otherwise showed lock acquisition as the very first step.
ENOLCK means the kernel is out of lock-record memory (transient), not "this
filesystem has no locking" — EOPNOTSUPP/ENOSYS are the real capability
signals. Treating ENOLCK as unsupported let a genuinely contended lock
proceed unlocked, reintroducing the interleaved-write race this PR exists to
prevent. Verified with a mocked ENOLCK against a real holder before the fix.
The new locks-dir chmod(0700) was unguarded and could hard-fail on the same
class of filesystem (CIFS/FAT/some FUSE mounts) the flock degrade was added
to tolerate; wrapped in contextlib.suppress(OSError) to match the other
best-effort guards in this function.
Also: reworded lock_path_for's docstring, which cited a name-leak rationale
two sibling fixes in this branch already made moot (sensitive files are
refused before the lock is ever taken, and the locks dir is now 0700); moved
a misplaced comment to sit above the checks it describes instead of above
the lock acquisition; and added test coverage for the degrade branch, the
ENOLCK exclusion, the chmod, and the no-lock-file-for-rejected-input case,
none of which the prior commits had tests for.
Two threads each entering their own mock.patch.object on the same module
attribute unpatch in start order, not entry order — whichever thread's with
block exits first restores the real call_claude/validate while the other
thread is still mid-flight. Confirmed empirically: running this test alone
left compress_mod.call_claude and compress_mod.validate as MagicMock
afterward, which would have leaked into any later test in the same process
that calls compress_file without patching them itself.
Sonnet 5's $10/M output rate is the permanent standard price -- the
2026-09-01 increase to $15/M was cancelled per Anthropic's pricing docs,
not a temporary intro window. Also removes claude-haiku-5, which does
not exist in Anthropic's current lineup (only Haiku 4.5 ships).
Addresses review feedback on PR #900.
* origin/main: (68 commits)
Cleanup
fix(proxy): scope repo intelligence to project source and require direct evidence (#901)
chore(release): cut @caveman-ai/cli 1.2.5
fix(cli): revive a dead local proxy mid-session, not just at SessionStart
fix(install): pin the shims at v2.3.1 and guard against pin drift
chore: sync SKILL.md copies [skip ci]
chore(release): cut v2.3.0 on the bin-v1.1.3 runtime
skill + docs update
chore(docs): remove the GitHub Pages landing page
docs: add the Cursor Tier 1 support plan
docs: mark learn v3 as shipped
chore(cli): regenerate the bundled agent skill registry
refactor(skills): rewrite the caveman ruleset's framing clauses
feat(skills): teach caveman-learn to report spend and attributed savings
feat(cli): surface learn spend, the savings ledger, and holdout reports
docs(proxy): record the spend-vs-savings rule for local learn
feat(proxy): log serve-mode warnings to ~/.caveman/proxy.log
fix(proxy/gateway): buffer non-streaming upstream bodies before replying
feat(proxy/store): show spend and attribution in the learn report
feat(proxy/store): add a privacy-safe export digest
...
* fix(proxy): skip vendor trees in repo intel
BM25 was indexing .pixi/envs and other dependency
dirs, then injecting those file:line guesses every
turn. Exclude known vendor trees and drop the path
block when no local scout is configured.
Closes#899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): detect dep trees by content, not name
Name denylist missed conda envs, undotted pixi, and
virtualenvs under any name, while dropping real
first-party build/ and vendor/ dirs.
- listFiles prefers `git ls-files --exclude-standard`,
so the project's own .gitignore defines vendored
- walk fallback identifies environments by contents
(conda-meta, pyvenv.cfg, site-packages child),
catching the interpreter stdlib they carry
- ambiguous build/dist/target/vendor need a sibling
manifest before exclusion
- symlink and junction dirs are never followed
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): treat the indexed repo as untrusted input
Second review found the git listing both unsafe and
incomplete.
- gitCommand pins core.fsmonitor and other exec
vectors off and strips GIT_*; fsmonitor runs during
an index read, so `ls-files` in a hostile clone ran
attacker code at session start
- git-listed paths now run the same content filter as
the walk, so an un-ignored conda prefix or committed
vendor tree no longer rides in on git's say-so
- an empty-but-successful listing means the cwd is
ignored by an enclosing repo; fall back to the walk
instead of reporting a complete map
- Direct matches the file name, not the whole path:
a term like "src" marked every file beneath it
- ListingBasis discloses which path ran, hashed in
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): log declined evidence, document invariants
A declined turn stays silent to the model but now
records repository_evidence_declined_no_direct_match,
so weak evidence is distinguishable from a broken or
disabled mechanism.
Adds a positive control to the suppression test and
records the gating, filtering, and git-hardening
invariants in proxy/CLAUDE.md.
Refs #899
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(installer): make prune path flavour explicit, not host-derived
pruneOrphanedManagedHooks guards against pruning a hook whose path is
absolute on the other platform. That guard is POSIX-only by construction:
win32's absolute set is a superset of posix's, so a Windows host reads
C:\... natively and prunes it. The test asserting the guard hard-coded a
Windows path and so failed on the windows CI runner.
Select the path flavour from an explicit platform arg (default
process.platform, same as rewriteLegacyManagedHookCommands) so the POSIX
side stays reachable from a Windows runner. Host behaviour unchanged.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* test(cli): escape stub preload path in NODE_OPTIONS
Node parses NODE_OPTIONS itself and treats a backslash inside a quoted
value as an escape, so the raw Windows path reached --require with every
separator eaten:
Cannot find module 'C:UsersRUNNER~1AppDataLocalTempcave-pi-enableXbinstub-dispatch.cjs'
Every stub-backed process died in the preload, failing all six pi enable
and wrap round trips. The quotes must stay for profile names with spaces,
so double the separators instead. POSIX is unaffected — stubEnv returns
before this on non-Windows.
Never caught before because npm test failed earlier in the same job, so
test:windows has not run since these tests joined the gate.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): harden every untrusted git call, not just repo intelligence
`repointel` hardened its own `git ls-files` and left three other call sites
raw. `nativehook.currentRepositoryState` runs `git status` on EVERY hook
event, and `status` refreshes the index, so it triggers `core.fsmonitor`
harder than `ls-files` does — verified: the sentinel hook fires. Same for
`store/detect_outcomes` and the two `execFileSync` sites in the CLI.
- `proxy/internal/gitsafe` and `packages/cli/src/git-safe.ts` now own the
`-c` override list; all five call sites route through them.
- `verify_repo.py` fails the build on a NEW raw `git -C <dir>` call site,
which is the regression class this bug actually belongs to.
- `Bundle.DirectOnly()` strips metadata-only items BEFORE the evidence
object is written. Filtering only at render time left the BM25-proximity
guesses stored behind the `ccr://` handle, one retrieval from the model,
which is what the block refuses to render.
- `gitListBudget` caps git at half the caller's remaining warm budget. 2s
of a 3s window left the fallback walk — the MORE expensive path — unable
to finish, so the map failed closed in exactly the case the fallback
exists for.
- `NormalizeTerms` floors terms at 3 characters, matching the hook that
produces them: direct evidence is a substring test on a file's own name,
so `go`/`py`/`js` marked every file of that language a direct hit.
Regression tests verified to fail without their fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(build): stop wedge generation racing itself on Windows
mcp, shrink, and browse each run gen-wedge-installer from their own
prepack/pretest, and every run writes all four targets, not just its own.
`pnpm -r test` runs those packages in parallel, so several processes
copy onto the same file at once and Windows fails the loser:
EBUSY: resource busy or locked, copyfile
packages/shared/binary-installer/installer.mjs
-> mcp/bin/binary-installer.generated.mjs
The bytes are identical every time and the outputs are committed, so the
race was over writes that change nothing. Skip when the destination
already matches, and land a real change through a temp file + rename so
no concurrent reader sees a partial file. Verified with a 12-way
concurrent storm, and against corrupted outputs to prove the write path
still restores them.
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
* fix(proxy): keep system git config readable while hardening
GIT_CONFIG_NOSYSTEM defends against nothing here — /etc/gitconfig is
root-owned, the threat is the repository's own .git/config, and the `-c`
overrides beat every config level anyway. What it does cost is org-wide
`safe.directory` allowances: without them `git status` fails "dubious
ownership" on shared and CI machines, repository state comes back empty,
and reuse silently stops. Dropped from both wrappers, with a test so it
does not come back.
Also names the fsmonitor tradeoff at the hot call site: disabling it taxes
the people who configured it because their repo is huge, and status has a
100ms budget. That degrades safely; moving the budget is a latency
decision for every user and needs a measurement this fix does not have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(build): pin wedge generation against the concurrency it now survives
The EBUSY fix has no committed regression proof, and the failure only
appears when several packages run the script at once — which is exactly
what `pnpm -r test` does and what a single-process check never reproduces.
Eight concurrent runs must all succeed and leave the shared outputs
byte-identical; a drifted output must still be repaired, so skip-if-
identical cannot regress into skip-always.
Runs in root `npm test`, which is the gate the Windows job failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Julius Brussee <JuliusBrussee@users.noreply.github.com>
Co-authored-by: Julius Brussee <juliusbrussee@getlockedin.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mode was applied per session but stored in one file per machine
($CLAUDE_CONFIG_DIR/.caveman-active). Four bugs followed from that one
fact: parallel windows shared a mode; deactivation was spelled "no flag
file", so SessionStart re-derived the configured default and undid an
explicit "stop caveman" after every auto-compaction; the statusline badge
rendered whichever window wrote last; and "off" could not survive a
SessionStart at all.
payload's `source`, but it read the legacy flag, where off is absence — a
deactivated session found nothing stored and fell back to getDefaultMode()
regardless. The other three are consequences of the storage shape, not of
when the hook re-derives.
State now lives in .caveman-sessions/<session_id>.mode, keyed by the
session_id Claude Code puts in every hook payload and in the statusline's
stdin JSON. The legacy flag stays as a last-write-wins compat mirror and
never receives the literal 'off': an older hook reading that would inject
"CAVEMAN MODE ACTIVE (off)" and an older statusline would render
[CAVEMAN:OFF]. Both spellings of off are read everywhere — a missing file
(old) and a literal 'off' (new, durable).
caveman-config.js gains the primitives: validateSessionId (a whitelist,
because a session id becomes part of a path), resolveActiveMode,
readSessionModeRaw, writeSessionMode, per-session prev for one-shot skills
(#599), gcSessionStore, and a session_id-tagged recordModeChange (#601).
Every one takes a sessionId that may be null or malformed and degrades to
the previous machine-wide behavior — the old code path IS the fallback
branch, which is why the existing suites, nonid,
pass unchanged.
SessionStart re-derives the default only on `startup` and `clear`; compact,
resume, fork, an unrecognized source and the n`
read the stored value. Grouping `clear` with the resets deliberately
differs from #691's comment: per-session storage makes the distinction
cheap, and nothing else in the conversation survives /clear either. The
watchdog must not reset, or a slow payload on a compaction re-arms a
session the user turned off.
The three entrypoints resolve the new helpers individually instead of
demanding them in their requireSibling shape checks (#848): a
caveman-config.js from before this change satisfies those checks, so
hard-failing would trade "machine-wide mode, no
state at all" on exactly the plugin-cache drift that guard exists for.
Both statusline ports take session_id from the stdin JSON with bounded,
TTY-guarded reads (integer timeout — bash 3.2 rejects fractional read -t)
and render nothing for a deactivated session. verify_repo.py greps them
against SESSIONS_DIRNAME and SESSION_ID_RE, tws
badge. caveman-stats filters the transition l
window's switches no longer skew another's es
(bin/install.js, uninstall.sh, uninstall.ps1) removes the session store;
.caveman-history.jsonl is kept on purpose. opide
behaviour — it writes its own flag in its own config dir and never sees a
Claude Code session_id.
Tests: 25 unit cases in tests/hooks/caveman-config.test.mjs,
SessionStartSourceTests, SessionScopedModeTests, 5 statusline cases, and
tests/manual/session-mode-smoke.sh (22 checks, end-to-end against a
throwaway config dir). docs/testing-session-modes.md carries the full plan
including the live-Claude-Code checks. Hook ced.
npm test 197/197 · tests/*.js clean · unittesss
· smoke 22/22
Signed-off-by: Pavel Filippenko <filippenko.ps@phystech.edu>
priceForModel() only matched Claude 3/4 prefixes, so claude-*-5 model ids
fell through to null and the Est. saved (USD) line, Pricing for <model>
footer, and (~) parenthetical were silently omitted on Claude 5
sessions. Add the Claude 5 rate card at the top of
MODEL_OUTPUT_PRICE_PER_M (Fable/Mythos 5 $50, Opus 5 $25, Sonnet 5
$10 intro through 2026-08-31 then $15 per shared/provider-catalog,
Haiku 5 $5) and extend the prefix-match regression test, including the
[1m] context variant Claude Code reports.
Signed-off-by: Som Samantray <som.samantray@gmail.com>
caveman-mode-tracker.js never installed an 'error' listener on
process.stdout/process.stderr. When the harness closes its end of the
pipe after the hook has already begun writing a payload (the per-turn
reinforcement or the /caveman-stats block), the write throws EPIPE as
an uncaught exception and the hook exits non-zero, surfacing as a red
"Failed to write to socket (Broken pipe, errno 32)" status line under
an otherwise normal prompt.
This mirrors #538, which added the equivalent listener on stdin for
the same reason: hooks must always exit 0, and an unlistened stream
'error' throws regardless of which side of the pipe closed.
Fixes#397
v2.3.0 bumped bin/install.js and the README/INSTALL one-liners but left
install.sh and install.ps1 pinned at v2.2.0. The curl-pipe path reads the
shim's own default ref, so `curl .../v2.3.1/install.sh | bash` bootstrapped
the previous release. Root package.json had drifted the same way.
Adds a test asserting the three pins, the installer package version, and
every documented one-liner name the same release, so the next bump cannot
miss one silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KA6VNr91eQ7MCQAB9E1LVN
zip -r adds to an existing archive and dist/caveman.skill is tracked, so a file
deleted from skills/caveman/ never left the shipped ZIP. Delete before zipping.
release-packages also triggers on pi-v*, and verify_repo checks all six mirrors
sync-skill.yml writes instead of only the first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
strip_llm_wrapper matched any document that merely started and ended with a
fence line, so an ordinary README section came back with both markers deleted
and its code blocks merged into prose. It now requires the two fences to be the
same block.
The validator gains CommonMark indented code blocks — a 4-space-indented
destructive command was prose to it, so a rewritten `kubectl delete` passed
clean and overwrote the user's file. Lost file paths and changed heading text
are errors now, not warnings; both were promised in SKILL.md and neither gated
the overwrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
resolvePluginRoot returns the repo root when the hook runs from a clone, so with
any CAVECREW_*_MODEL set every SessionStart rewrote tracked agents/*.md and
dirtied the tree. Skip when the plugin root sits inside a git work tree; an
installed plugin under $CLAUDE_CONFIG_DIR still gets overrides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
Uninstall aborts instead of removing hook files whenever settings.json could
not be parsed or rewritten — leaving them while the entries remain is #471 on
every session start. The POSIX and PowerShell uninstallers exit 1 on a missing
node for the same reason.
hooks/package.json is the one HOOK_FILES entry that is not ours: install now
leaves another plugin's manifest alone and uninstall deletes only the exact
one we ship. Temp downloads move to a 0700 mkdtemp so a pre-planted symlink
cannot capture the init script we then execute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
07cb21b dropped the four commands/*.md stubs that duplicated skills/*/SKILL.md,
which left this file asserting the opposite: ten #571 cases demanded a .md for
every documented command.
#571's premise no longer holds. Claude Code loads commands/*.md as flat skills
in the same namespace as skills/*/SKILL.md, so shipping both registers the name
twice — `claude plugin details` listed caveman, caveman-commit, caveman-review
and caveman-stats twice each. The stubs were the only registration when #571
landed; skills/ auto-discovery from the plugin root superseded them.
Replace "a .md must exist" with "exactly one provider must exist", which still
fails on the "Unknown command" regression #571 guarded against and now also
fails on the duplication.
Drop the stats-body assertion. It encoded a wrong model of the flow — that
Claude Code expands a command body before UserPromptSubmit sees it, so the body
had to re-emit the command text for the hook regex to catch. Verified against
Claude Code 2.1.235 with a hook that logs its stdin: invoking a plugin skill
delivers the raw typed prompt ("/probe:probeskill"), not the expanded body.
Assert what the handler actually receives instead: the bare and plugin-
namespaced forms of the typed command.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
read_source took one CRLF anywhere in the file as a mandate to write every
line CRLF; a mixed-ending document now takes the terminator the majority of
its lines use. write_text_atomic normalises before rendering, so model
output that already carries CRLF no longer becomes "\r\r\n". The backup is
written from the raw source bytes instead of re-rendered text, so it is a
byte-for-byte copy of what was there.
evals/llm_run.py resolves `claude` through shutil.which — npm installs it as
claude.CMD on Windows and CreateProcess does not apply PATHEXT, so the bare
name raised FileNotFoundError — and decodes subprocess output as UTF-8 with
errors="replace" rather than inheriting a cp1252 console codec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
Marker handling, both directions. `installOpencode` and caveman-init treated
"both markers present" as a fence, so an orphan BEGIN or an END above a
BEGIN ran the slice arithmetic on end === -1: AGENTS.md re-appended itself
from byte 19 and compounded on every re-run, and a per-repo refresh would
have replaced everything between an orphan BEGIN and a later block's END,
user content included. Both now require exactly one matched pair, in order,
and report damage instead of writing.
Uninstall ordering. uninstall.sh/uninstall.ps1 deleted the hook files first
and edited settings.json second, so a failed or refused edit left Claude
Code pointing at scripts that no longer exist — `Cannot find module
…caveman-activate.js` on every session start (#471). Settings first; files
only once nothing references them. Neither script overwrites an existing
.bak any more (it is the user's only pre-caveman copy), and a JSONC
settings.json now aborts the edit with the working command instead of
half-uninstalling.
install.ps1's "already wired" probe matched the bare substring `caveman`,
which any user hook mentioning the word in a path satisfies (#593) — it
silently skipped wiring and left caveman inactive. It now probes for the
exact script we write per event, mirroring install.sh.
The SessionStart watchdog no longer claims `startup` when it fires. That is
the one source that resets the mode, so a payload slow enough to time out on
a compact/resume event dropped a mid-session `ultra` back to the default.
An unknown source preserves a valid existing flag, and the deadline moves to
3s — still well inside the host's 5s budget, but above a cold Windows/AV
start.
caveman-init writes retry a rename that Windows loses to OneDrive/AV
handles (EPERM/EBUSY/EACCES), the shrink MCP refuses an implausible .cmd
shim before reading it, and pruneOrphanedManagedHooks no longer prunes a
hook whose path is absolute on the other platform — existence is only
knowable for our own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
Three loader facts the manifest was fighting:
- An explicit `agents` array loads ZERO agents. `claude plugin details
caveman` reports "Agents (0)" with the array and "Agents (3)" without it,
so the cavecrew subagents the cavecrew skill delegates to did not exist
for plugin users. Drop the key and rely on the default agents/ scan.
- That scan takes EVERY top-level .md in agents/, so agents/AGENTS.md and
agents/CLAUDE.md — maintainer docs for the profile registry — shipped as
subagents named AGENTS and CLAUDE. They move to agents/docs/, which the
scan does not recurse into.
- commands/*.md load as flat skills beside skills/*/SKILL.md, so each .md
stub registered its skill's name a second time: `caveman`,
`caveman-commit`, `caveman-review` and `caveman-stats` each appeared
twice, a 3-line stub competing with the real ruleset for the same slash
command. The .toml stubs are Codex/Gemini-only and are not scanned.
verify_repo.py now guards all three, and its skills-are-documented check
only counts a name inside a code span — a bare substring search over the
whole document meant `skills/migration/` was "documented" by any prose
sentence using the word, so the guard could not fail for the drift it
exists to catch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmbqWDtNexzDfH5V3BMRz
_file_with wrote its fixture with write_text(encoding="utf-8") and no
newline="", so Python's text mode rewrote every \n as \r\n on Windows. The file
under test was therefore CRLF, compress.py correctly PRESERVED that (#762), and
test_utf8_roundtrip_survives_compression compared the CRLF result against its LF
expectation and failed. The assertion was measuring the fixture's line endings,
not the compressor's behaviour — the failure was evidence the product is right.
Pin the fixture with newline="". CRLF preservation itself stays covered by
test_crlf_line_endings_survive_the_round_trip, which writes bytes and asserts
bytes, so nothing is lost by making this one honestly LF.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
Whole-repo review of the surfaces that mutate a user's agent config, plus a
cross-platform sweep focused on Windows.
Hooks (Windows):
- caveman-mode-tracker finished its work in ~50ms then blocked until the host
killed it at the 5s budget: the 'data' listener REFERENCES the stdin handle,
so pause() alone keeps the event loop alive until the host closes the write
end, and on Windows that close lags arbitrarily. Reproduced off-Windows.
This is the shape of #819. Fixed with process.stdin.unref().
- caveman-activate used readFileSync(0), which blocks INSIDE the read syscall
until EOF — no deadline can interrupt it. The #729/#833 fix had been applied
to the tracker and never to its sibling. Rewritten event-driven, acting on
the first complete payload, with a watchdog so a payload that never arrives
still activates instead of forfeiting the session.
- hookCommand emitted PowerShell call-operator syntax into settings.json, but
Claude Code's Windows hook shell is Git Bash, which rejects it outright:
`syntax error near unexpected token '&'`. Every standalone Windows install
had two dead hooks (#835). Both emitters now route through one bash-safe
shape.
- activate now threads the payload cwd, so a checked-in .caveman.json
defaultMode "off" is honored for the session's directory (#634), and a JSONC
settings.json no longer suppresses the statusline nudge.
Config ownership:
- src/hooks/uninstall.{sh,ps1} matched hooks by the substring "caveman" and
filtered whole entries, deleting user hooks that merely mention the word and
dropping foreign handlers that shared a matcher group (#593). Ported the
exact-basename matcher from bin/lib/settings.js and switched to filtering the
inner handler list. Verified against real PowerShell: the old code left zero
user hooks standing.
- install.{sh,ps1} backed up settings.json unguarded, so a --force reinstall
overwrote the only pre-caveman copy with the merged file. Back up once.
- OpenClaw overwrote a hand-edited workspace skill with no backup.
- caveman-init appended unfenced blocks to tracked repo files, so they could
be neither refreshed nor removed; now marker-fenced and written atomically.
- opencode AGENTS.md and OpenClaw SOUL.md were presence-checked, so a ruleset
edit never reached anyone already installed. Both refresh in place.
- opencode's system-prompt hook had no idempotency guard; it now rewrites its
line rather than stacking another onto the context window.
- readSettings reports when it stripped comments so both write paths warn and
point at the backup instead of silently dropping a user's annotations.
Skills: skills/ is auto-discovered wholesale by the plugin loader, so 13
undocumented skills would have shipped into every user's agent on the next
release. Documented them and added a verify_repo check that fails when a
skills/ directory is named in neither README.md nor CLAUDE.md.
CI: a windows-powershell job covering the surface engine-ci never exercised —
the PowerShell installer round trip, and a bash -n gate over every hook command
the installer writes. That gate alone catches #835.
Tests: every fix above is pinned by a test that fails against the old code,
including tests/test_hook_stdin_lifecycle.js, which reproduces the Windows
lagging-close condition on any platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
Findings from a whole-repo cross-platform audit.
Data loss:
- evals/llm_run.py wrote the snapshot with write_text(ensure_ascii=False) and
no encoding: on Windows that truncates results.json to 0 bytes, then raises
at the first non-cp1252 char. Destroyed the committed snapshot and the run.
- compress.py read the user's target with errors="ignore", silently dropping
undecodable bytes, backing up the mangled text, passing its own readback
check, then overwriting the original. Now decodes strictly and refuses.
Broken on Windows:
- benchmarks/ and evals/ read SKILL.md, README.md and results.json without an
encoding; all three raise on a cp1252 decode.
- cavemem's Python client used text=True with no encoding, so a non-ASCII
memory failed to store and any recall came back mojibake.
- native-hook-fast spawned a second full Node process from inside a hook with
no timeout at all.
Also fixed:
- Backported the pnpm cross-drive shim fix to the five copies that never got
it; hoisted two inline parsers so all of them are unit-testable.
- portable-command now resolves through PATH/PATHEXT instead of silently
no-op'ing on a bare command name.
- uninstall.ps1 writes a temp .js instead of node -e (matches install.ps1).
- Real backoff on the flag rename retry; EPERM/EBUSY retry when replacing a
running binary.
- Hooks act on the first complete JSON payload instead of blocking on EOF;
the stats child timeout drops below the host's hook budget.
- Statusline tests run the .ps1 on Windows instead of skipping.
- Added the macOS CI job the repo never had.
Left unfixed and annotated in place: the win32 hookCommand emits PowerShell
call-operator syntax while #835 describes the runner as git-bash /bin/sh.
Needs a real Windows host to settle; guessing would break whichever works.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NveRZs9mV97iX7HSgq3BYF
print() on the lock-wait path had no flush=True — the script's stdout
is a pipe when invoked as `python3 -m scripts <file>`, so Python
block-buffers it and the message the print exists to show (900s of
otherwise-silent waiting) never actually reached the user. Move the
print after the deadline check too, so a run whose budget is already
exhausted doesn't emit "waiting" immediately before raising
LockTimeoutError on the same iteration.
The integration test capped LOCK_WAIT_SECONDS at 2s but left
LOCK_POLL_INTERVAL at its real 1s default, leaving only two poll
attempts inside that budget — a holder running past ~2s on a loaded
CI box would flip the waiter into a LockTimeoutError with no timeout
in the failure message. Patch LOCK_POLL_INTERVAL down alongside it,
matching the pattern the other lock tests already use. Also make the
one remaining non-daemon lock-holding thread a daemon for consistency
with the rest of the file.
lock_path_for hand-copied backup_dir_for's (parent-dir-name, stem) shape
instead of deriving from it, so a future edit to backup_dir_for could
silently reopen the exact collision this lock exists to close. Compute
the lock key from backup_dir_for's own output instead.
Two concurrency tests never mocked LOCK_WAIT_SECONDS down from its real
900s default and joined non-daemon threads — a lock regression would
have hung the test run for up to 15 minutes instead of failing fast.
Patch it to 2s in both, matching the other tests, and make all the
lock-holding threads daemons as a second line of defense.
Also: print once when a run starts waiting on another session's lock,
since 900s of silence reads as a hang; add a test proving two distinct
files in the same directory still get separate locks (the negative
case for the domain fix); and strengthen the domain-match test to the
sharpest real collision (task.md vs task.txt sharing one stem).
lock_path_for hashed the full resolved path while backup_dir_for keys
its own collision guard on (parent-dir-name, stem) only — two source
files sharing that pair took separate locks while writing the same
backup path, reproducing the exact race this lock exists to prevent.
Key the lock on the same (parent-dir-name, stem) tuple, hashed so a
lock for a file later refused as sensitive (e.g. id_rsa) never leaks
its name into the state directory.
Also: narrow the Windows OSError->BlockingIOError conversion in
_try_lock_nonblocking to the documented contention errno (EACCES) so a
real failure (bad fd, permissions, AV lock) isn't misreported as
"another session is compressing this file" after a 120s stall; refuse
to use the lock directory if it's a symlink (mkdir(exist_ok=True)
would silently follow one); open the lock file with mode 0o600 to
match this repo's own safeWriteFlag convention for predictable-path
state files; and raise LOCK_WAIT_SECONDS from 120 to 900 after
measuring ~78s for a single compress call on a 26KB file against this
repo's own CLAUDE.md — the old budget hard-failed a healthy run well
under the 500KB size cap.
* origin/main: (62 commits)
ci: make the suite-hang class structurally impossible
test: force-exit runtime suites so no leaked handle can ever hang CI again
test(cli): strip CI from the telemetry tests' isolated env
fix(cli-tests): stop telemetry stub leak from hanging the suite; un-flake the 400ms token budget under load
test(sdk-py): read parity fixtures as UTF-8 so Windows cp1252 cannot mojibake unit keys
chore: sync SKILL.md copies [skip ci]
fix(compress): survive cp1252 consoles; skip POSIX shell-install tests on Windows
test(win): fix EBUSY cwd-in-rmdir in repo-local-config test, skip POSIX symlink-write subtests
ci(win): also exclude @caveman-ai/agent from windows pnpm -r test
ci(win): portable npm spawn in shrink test; exclude unported cli suite from windows pnpm -r test
test(win): extend browse tests' existing win32 skip to the shebang-stub test, spawn npm portably
test(win): skip POSIX-stub cavemem tests on Windows, spawn npm portably in mcp test
release: cli 1.2.2 on bin-v1.1.1 runtime
test(installer): make platform-paths and legacy-hook-rewrite tests host-independent
fix(learn): make reread/MCP-tax scans deterministic on Windows
test(ci): unstick main — sync lockfile, un-hardcode PINNED_REF pins, expect opencode.jsonc
release: pin bin-v1.1.1 runtime (transport retry, wrap keepalive #860)
release: cli 1.2.1 on bin-v1.1.0 runtime
fix(proxy,cli): heartbeat wrap-owned proxy so idle exit cannot strand live sessions (#860)
fix(proxy): retry transient upstream transport failures on buffered requests
...
# Conflicts:
# skills/caveman-compress/scripts/compress.py
The compress skill script crashed on Windows printing its emoji status
lines under a cp1252 console (UnicodeEncodeError in five tests, and for
any real /caveman-compress run there) — stdout/stderr now replace
unencodable characters instead of crashing. The three hook tests that
drive install.sh/uninstall.sh via bash skip on win32: that's the POSIX
install path, Windows installs through install.ps1. Full python suite: 67
OK. plugins/ mirror of compress.py follows via sync-skill (scripts/** is
in its trigger).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb
Last two windows failures in the root hook tests: the elsewhere-dir subtest
removed a directory while chdir'd inside it (Windows EBUSYs on that), and
four subtests assert safeWriteFlag SUCCEEDS through a symlinked parent —
on Windows it fails closed (no getuid/O_NOFOLLOW semantics; silent-fail per
the hook invariant), which is the conservative behavior, so those are
POSIX-only like the existing perms subtest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez7w7BBtKrMZCiZz9ea4cb