Skip to content

Sync-by-default with opt-in per-session /caveman isolation - #816

Open
eggrollofchaos wants to merge 38 commits into
JuliusBrussee:mainfrom
eggrollofchaos:feat/session-sync-opt-in-isolation
Open

Sync-by-default with opt-in per-session /caveman isolation#816
eggrollofchaos wants to merge 38 commits into
JuliusBrussee:mainfrom
eggrollofchaos:feat/session-sync-opt-in-isolation

Conversation

@eggrollofchaos

@eggrollofchaos eggrollofchaos commented Aug 9, 2026

Copy link
Copy Markdown

Motivation

This replaces the closed PR #800, "Per-session mode flag isolation." That PR correctly implemented per-session /caveman isolation, but every session isolated unconditionally on its own SessionStart, before the user ever ran /caveman <level> -- silently changing the default behavior for everyone from "all sessions share one toggle" to "every session permanently isolated." That's a bigger, more disruptive default-behavior change than intended, and not something a maintainer should have to infer from "a follow-up PR is coming" -- so it was closed without merging rather than patched in place.

This PR is atomic and self-contained: it restores the shared/synced-by-default behavior caveman has always had, while adding per-session isolation as something a user opts INTO, not something that happens automatically.

The model

  • Untouched sessions stay synced -- reading one shared .caveman-active flag live, exactly like caveman worked before this PR existed. Verified against upstream's actual pre-scoping mechanism (git show ec83e5b:src/hooks/caveman-activate.js) to make sure this isn't a reinvention.
  • /caveman <level> isolates that one session -- it locks to the level you picked and stops following the shared default, even if the shared default later changes to that exact same value.
  • /caveman default (new) reverts an isolated session back to following the shared default live.

What changed vs. the closed PR's code

The cross-implementation isolation machinery from the closed PR (session-id sanitization, resolveFlag/resolvePrev ENOENT-vs-rejected semantics, JS/Bash/PowerShell parity, 71 tests) is carried forward as-is -- it already cleared 13 rounds of review. Only three files have new logic:

  • caveman-activate.js: unified write-target resolution. The write target is now always resolveFlag(...).path for every event type (startup, resume, clear, compact) -- not a scoped path computed unconditionally before branching. True startup on a synced session is the only case that refreshes from the configured default; every other case just preserves whatever's already there. This also means an isolated session now correctly stays isolated across a process restart (claude --resume), not just mid-conversation re-fires.
  • caveman-parse.js / caveman-mode-tracker.js: new /caveman default command.

Developed through several rounds of design review before any code was written -- two real design bugs were caught and fixed at the design stage: the original write-target design would have reintroduced implicit isolation on a synced session's first resume/compact re-fire, and a related bug where an isolated session's emitted rules would diverge from its stored mode across a process restart.

Test plan

  • All existing 71+ tests pass, plus new regression coverage for every case above (tests/test_hooks.py, tests/test_mode_tracker_stdin.js).
  • Manual smoke: two sessions sharing a config dir stay independently isolated; /caveman default reverts one back to tracking the shared value live.

Closes nothing directly (#800 already documents the incident); supersedes #800.

Design + implementation-ready checklist for scoping the caveman mode
flag (and its associated .prev / mode-log state) per Claude Code
session_id instead of one shared global file across every session on
the machine.

Origin: 2026-07-06 debugging session traced /caveman off "coming back
on its own" to the SessionStart hook unconditionally rewriting one
global flag file on every session start anywhere; user asked for
per-session on/off. A prior plan (same intent, different draft) was
reviewed 2026-07-10 with 1 High + 3 Medium findings but never revised
or implemented before local main fast-forwarded past the attempt.
Re-derived fresh against the current upstream tree (ec83e5b), which
has moved substantially since that review (bin/ -> cli/, shared
caveman-parse.js, synchronous stdin read for JuliusBrussee#691 resume-preserve
logic that removes the exact async-timeout hazard the prior High
finding was about).
Same convention ai-coding-agents uses for its review/ directory —
review packets, claims, and run artifacts for this branch's plan
review shouldn't land in the tracked tree.
Tier-1 headless review of v1 (target a521bc7)
found real design gaps, not false positives:

- Critical: shell sanitizer sketch stripped invalid chars + truncated
  instead of rejecting whole value (../../etc/passwd -> etcpasswd).
- Critical: "absent scoped file falls back to legacy" collided with
  /caveman off deleting the scoped file -- turning off in one session
  could resurrect another session's mode on the next read. Fixed: off
  is now written as literal content, never deleted.
- Critical: caveman-stats.js would derive session id from the
  transcript filename unconditionally, split-braining with a hook that
  itself fell back to the legacy path. Fixed: tracker threads its own
  sanitized session id into stats explicitly via --session-id.
- High: readFlag collapses missing/symlink/oversized/invalid into one
  null, so naive scoped-then-legacy fallback would expose another
  session's mode on a corrupted/attacker-planted scoped file. Fixed:
  shared resolveFlag() distinguishes ENOENT (fallback) from
  exists-but-rejected (fail closed, no fallback).
- High: recordModeChange's prev lookup read the hardcoded legacy path
  regardless of caller. Fixed: resolves via the same shared resolver.
- High: mode-log null-session filter let ongoing legacy/no-session
  activity bleed into scoped sessions indefinitely, not just historical
  rows. Fixed: only truly keyless (pre-migration) rows always join; an
  explicit session_id:null row only joins another legacy-path reader.
- High: every checklist item named bin/install.js, which was renamed to
  cli/install.js upstream before this plan was drafted.
- Medium x2 + Nit: relaxed the byte-for-byte acceptance gate to name the
  one intentional additive mode-log field, made stats flagMtimeMs stat
  the same resolved path attribution reads mode from, and fixed a
  near-miss uninstall test fixture that actually matched the intended
  charset.
v2 (target 82f37cc) cleared all
Criticals but returned 5 more High + 2 Medium -- precision gaps in
the checklist, not new design flaws:

- High: activate.js/mode-tracker.js checklist told READS of current
  state to use the same flagBaseName helper as WRITES, which never
  falls back -- a valid session id with no scoped file yet plus an
  active legacy flag would read as inactive instead of inheriting
  legacy. Fixed: resolveState() generalized to cover both flag and
  .prev (resolveFlag/resolvePrev); reads go through the resolver,
  writes use flagBaseName/prevBaseName directly. Stated per call site.
- High: only the reinforcement-emit check used isActiveMode(); the
  independent-mode current-capture and prev-restore-decision still
  treated literal 'off' as an active mode, corrupting one-shot state
  and mode-log entries after off -> commit -> ordinary prompt. Fixed:
  both gates use isActiveMode() too; a restored 'off' normalizes to
  null in the mode-log.
- High: mode-log filter's (row.session_id || null) === (sessionId ||
  null) coerced any falsy-but-present value (empty string, 0, false)
  to null, letting a corrupted row masquerade as legacy. Fixed: strict
  validation in readModeLog -- present session_id must be null or pass
  sanitizeSessionId; anything else is malformed and excluded from
  every reader.
- High: plan only touched cli/install.js's uninstall function; missed
  the two standalone entry points src/hooks/uninstall.sh and
  uninstall.ps1, which also remove only the exact legacy flag path.
  Fixed: both gain the same enumeration.
- High: Acceptance Gates claimed byte-identical legacy flag content
  while Phase 2 separately required legacy /caveman off to write
  'off' instead of unlinking -- direct contradiction. Fixed: gate now
  states path selection is unchanged but the off-representation
  change is universal, on the legacy path too.
- Medium x2: docs promised /caveman-stats reports the resolved flag
  path but the implementation task never required exposing it (now
  threaded into formatStats/formatShare); statusline test plan mostly
  proved sanitizer decisions, not the full ENOENT-vs-rejected
  file-state matrix (now added to Phase 4 + both statusline tasks).
v3 (target 710fff4) cleared all
Criticals and prior Highs but returned 3 new High + 2 Medium:

- High: activate.js's "every write in this file" wording could scope
  the global .caveman-nudge-shown marker. Fixed: narrowed to "every
  write to the active-mode flag specifically," nudgeMarkerPath
  explicitly untouched.
- High: recordModeChange still compared raw current/newMode, so a
  resolved current === 'off' vs next === null still logged a spurious
  transition even with every v3 call-site fix in place. Fixed:
  isActiveMode normalization moved inside recordModeChange itself --
  single source of truth, callers no longer pre-normalize.
- High: plan only named tests/test_mode_tracker_stdin.js for new
  coverage, missing that tests/test_mode_tracker.py (12 assertions)
  and tests/verify_repo.py (3 assertions) assert the OLD
  unlink-on-off behavior this plan intentionally changes -- they'd
  fail under the whole-plan test-suite gate. Fixed: added explicit
  checklist item enumerating every assertion needing an update.
- Medium x2: Non-Goals claimed .caveman-active.prev (legacy) is an
  untouched deferred uninstall gap, contradicting Phase 5's own
  enumeration regex and test fixture; stats section claimed
  byte-for-byte manual/lifetime output identity, contradicted by the
  same plan requiring a universal Flag file: output line. Both fixed
  by narrowing the claims to what's actually true.
v4 (target 32005d9) cleared every
prior finding at every severity but returned 3 new High -- subtle,
distinct issues, not repeats:

- High: resolvePrev was a bare resolveState(..., prevBaseName) alias,
  symmetric with resolveFlag -- wrong for .prev specifically. A
  session with scoped active state but no scoped .prev would ENOENT
  and fall back to a stale legacy .prev, leaking another session's or
  caller's previous mode. Fixed: resolvePrev now gates its own legacy
  fallback on whether the session's scoped active flag exists at all.
- High: resolveState returned mode: null for both "never touched"
  (ENOENT) and "touched but rejected" (symlink/oversized/corrupted),
  and activate.js's resume-preserve check couldn't tell them apart --
  a rejected scoped file fell through to getDefaultMode() instead of
  staying inactive, contradicting the plan's own fail-closed
  principle. Fixed: resolveState now also returns rejected:
  true/false; resume explicitly resolves to 'off' when rejected,
  never the configured default.
- High: the test-update checklist claimed "twelve" assertIsNone
  assertions in test_mode_tracker.py all need to change to expect
  off, but three of the actual thirteen occurrences (lines 107, 109,
  113) assert "never activated," not "deactivated," and must stay
  asserting absence. Fixed: every occurrence individually classified
  by line number, verified against the actual file content.

recordModeChange's isActiveMode normalization (moved internal in v4)
is unaffected by this round -- all three findings are precision gaps
in resolvePrev/resolveState/test-enumeration, not new design flaws in
the mode-log or activate/tracker write paths.
- resolvePrev: reuse resolveFlag's own fail-closed identity check instead
  of a second fs.existsSync stat that re-collapsed ENOENT with every other
  error (v5's fix moved the same bug, didn't remove it)
- test enumeration: add test_mode_tracker_stdin.js (1 of 8 assertions) and
  test_caveman_parse.js (parity-oracle line) to the Phase 4 checklist,
  ground-truth verified
- correct Design/Non-Goals/Invariant-Matrix/Phase-5 description of
  cli/install.js's uninstall: it already has a STATE_FILES_TO_REMOVE loop
  (upstream JuliusBrussee#635 fix), not two exact-path unlinks; scope this plan's work
  to adding only the scoped-variant enumeration pass
- add README.md custom-statusline doc fix to Phase 5 checklist
- fix spec.md's stale "never removes" follow-up bullet to match
- cli/install.js scoped enumeration: require opts.dryRun honored and
  ENOENT-safe readdirSync (missing config dir must not throw)
- uninstall.sh scoped enumeration: replace GNU-only find -maxdepth with a
  portable shell-glob loop (BSD find on macOS rejects -maxdepth)
- resolvePrev: distinguish ENOENT from other stat errors in `rejected`,
  matching resolveState's own contract

Sixth review round; hit the round-economics trip-wire. Findings fixed
directly without spinning another dedicated Tier-1 round.
- fix self-inflicted regression: Design section still prescribed the
  GNU-only find -maxdepth v7's Phase 5 checklist had already replaced
- portable glob loop: guard against dangling symlinks ([ -e ] || [ -L ])
  not just non-matches, so uninstall doesn't leave rejected state behind
- uninstall.ps1: guard Get-ChildItem against a missing config directory
  (script runs with $ErrorActionPreference = Stop)
- docs: drop the most-recently-modified-file heuristic for "check my
  session's mode" (wrong under concurrent sessions); point at
  /caveman-stats only

Seventh review round, xhigh effort per the round-economics trip-wire.
Reviewer classified all findings as the same recurring class, not new
architecture. Full grep sweep for maxdepth/glob-loop stale copies done
post-fix.
…ium)

- fix Bash sanitizer illustrative snippet: case-glob pattern only
  anchored the first char, reintroducing the exact path-traversal class
  the plan claims to have already fixed; use anchored [[ =~ ^...$ ]]
- fix PowerShell sanitizer task: name the anchored -match idiom
  explicitly (.NET -match is unanchored by default, same pitfall family)
- reword resolvePrev design commentary nit (no functional change)

First Tier-2 cross-agent confirmation round after 8 Tier-1 headless
rounds; reviewer independently re-derived every ground-truth claim
against source and found this genuine gap in an illustrative code
sample the 8 prior rounds hadn't specifically probed.
- PowerShell sanitizer: .NET's $ matches before a trailing newline
  (unlike bash/JS); use \z instead of a trailing $ to fully close the
  anchor bug the v9 fix only partially closed
- mode-tracker.js Phase 2 checklist: name the three .prev WRITE/unlink
  call sites explicitly (capture write, clear unlink, restore unlink) --
  the checklist gave a precise mapping for reads but not writes, the same
  completeness-gap class as five prior findings in this document

Second Tier-2 cross-agent round (final confirmation pass). Both fixes
mechanical and verified live against adversarial input by the reviewer.
Tier-2 v3 returned clean (0 Critical/High/Medium, 1 non-blocking Low).
Records the review-decisions disposition for the deferred Low finding
and closes out the 11-round (8 T1 + 3 T2) review cycle before starting
implementation.
Scopes the caveman mode flag (and .prev, mode-log) per Claude Code
session_id instead of one global file shared by every session, with an
explicit backward-compatible fallback to the legacy global path for any
caller without a valid session_id.

- caveman-config.js: sanitizeSessionId (reject-not-strip), flagBaseName/
  prevBaseName, isActiveMode, and the shared resolveState/resolveFlag/
  resolvePrev resolver (ENOENT-vs-rejected fail-closed distinction).
  recordModeChange takes an explicit sessionId and normalizes 'off' to
  null internally so it's never logged as a distinct mode.
- caveman-activate.js: extracts + sanitizes session_id from the existing
  synchronous stdin read, writes to the scoped path, branches the
  resume-preserve check on resolveFlag's rejected flag.
- caveman-mode-tracker.js: threads session_id through every read/write/
  unlink call site (independent-mode capture, one-shot restore, clear
  action); 'off' is written as content, never unlinked as the on/off
  sentinel (.prev unlinks stay unlinks -- that's transient one-shot-
  restore state, not the sentinel). Passes --session-id to the stats
  subprocess.
- caveman-stats.js: --session-id argv parsing (re-sanitized, defense in
  depth), resolveFlag for the read path, relevantModeLogRows filtering
  (keyless historical rows always join; a present session_id must match
  exactly, never coerced via ||; malformed rows join nobody). Renders
  the resolved flag path in stats output. History/lifetime aggregation
  key is unchanged (still derived from the transcript path).
- caveman-statusline.sh / .ps1: read stdin, extract + whole-string-anchor
  validate session_id (bash [[ =~ ^...$ ]], PowerShell \z not a trailing
  $ -- .NET's $ matches before a trailing newline), same ENOENT-vs-
  rejected fallback as resolveFlag. A resolved mode of 'off' renders
  nothing.
- Tests: test_mode_tracker.py (10 assertIsNone -> expect 'off' content,
  3 stay unchanged), verify_repo.py (3 assertions), test_mode_tracker_stdin.js
  (1 assertion), test_caveman_parse.js (parity-oracle mapping) updated to
  match the on/off-representation change. All pre-existing tests still
  pass (3 known-preexisting failures unrelated to this change, confirmed
  via git stash against the pre-change baseline).

Implements the plan at docs/plans/per-session-flag-isolation.md, cleared
through 8 Tier-1 + 3 Tier-2 cross-agent review rounds (v1-v10). Remaining
phases (shared test-vector suite, uninstall enumeration, docs) land in
follow-up commits.
All three uninstall entry points now enumerate and remove scoped
.caveman-active-<session_id>[.prev] files, not just the legacy exact
names:

- cli/install.js: adds an ENOENT-safe, opts.dryRun-honoring readdirSync
  pass after the existing STATE_FILES_TO_REMOVE loop (left untouched).
- uninstall.sh: adds the STATE_FILES_TO_REMOVE-equivalent exact-name
  array (this standalone script had none of the JuliusBrussee#635 fix) plus a
  portable shell-glob loop for the scoped variants -- not `find
  -maxdepth` (GNU-only, rejected by BSD find on macOS) -- guarded
  against dangling symlinks ([ -e ] || [ -L ], not bare [ -e ]).
- uninstall.ps1: same two additions, PowerShell idiom (Get-ChildItem +
  -match), guarded with Test-Path against a missing config directory
  (this script runs with $ErrorActionPreference = "Stop").

.caveman-history.jsonl is deliberately kept (lifetime ledger), matching
cli/install.js's existing behavior, in all three scripts. Manually
verified (real, symlinked, and near-miss fixtures; missing-config-dir
case) against Node, bash, and pwsh on this machine -- all three exactly
match the plan's Phase 5 acceptance criteria.
… 5, part 2)

CLAUDE.md, INSTALL.md, and src/hooks/README.md described the mode flag
as a single global file and "off" as file-absence -- both now stale.
Updated every prose/ASCII-diagram description to reflect the per-session
scoped path with legacy fallback, and "off" as written content.

- README.md's "Custom statusline" code sample no longer hand-duplicates
  the resolver/rendering logic (which would drift out of sync) -- it
  now redirects readers to invoke the shipped caveman-statusline.sh
  directly with the same stdin JSON.
- Troubleshooting/uninstall sections updated to name the scoped file
  pattern and point at /caveman-stats's own resolved-path report,
  explicitly NOT suggesting a most-recently-modified-file scan (that
  heuristic can point at a different concurrent session's mode).
- tests/test_session_scoping.js (new): sanitizeSessionId vector table
  exercised directly AND through the Bash/PowerShell statuslines
  (cross-implementation parity -- an invalid id never produces a
  stripped/truncated scoped path in any of the three); a full
  file-state matrix (ENOENT, valid, off, invalid, oversized, symlink,
  non-ENOENT stat failure) proving the legacy sentinel is read ONLY on
  true ENOENT; direct resolvePrev exercise of the non-ENOENT stat
  failure asserting rejected: true (Tier-1 v6 Medium finding). Skips
  the PowerShell leg (with a printed reason) when pwsh/powershell isn't
  on PATH, and the permission-based non-ENOENT sub-case when running
  as root or on win32.
- tests/test_mode_tracker_stdin.js: two concurrent sessions toggle
  independently including one turning off while the other stays on
  (Acceptance Gates); a session with no session_id still uses the
  legacy flag; a scoped session's independent-mode capture/restore
  never touches the legacy .prev file (Tier-2 v2 High finding
  regression test).
- tests/test_caveman_stats.js: --session-id A only reflects session
  A's mode-log rows; keyless pre-migration rows join a scoped reader
  too while legacy-fallback (session_id: null) rows don't; a malformed
  session_id row ("", 0, false, path-traversal) never joins any
  reader (proves the v2 || coercion bug doesn't recur); no
  --session-id still reads the legacy flag exactly as before
  (regression check); stats output includes the resolved flag path in
  scoped/legacy-fallback/fail-closed outcomes.

51 total new/extended test cases across 3 files, all green. Full
existing suite still passes with zero regressions (3 known
pre-existing failures, confirmed unrelated via git stash against the
pre-change baseline, unchanged).
docs/plans/ isn't an existing convention in this project -- it was
carried over from unrelated personal tooling and doesn't belong in an
upstream contribution. Removes it from the branch; the code, tests, and
user-facing docs changes are unaffected.
…High)

- statusline.sh: check the actual file size against the 64-byte cap
  before reading (not just head -c 64), and exact-match the trimmed
  content against the whitelist instead of stripping invalid characters
  first. The old strip-then-whitelist pipeline let an oversized file
  starting with a valid mode word, or a value like "f u l l", reduce to
  a valid-looking mode -- the same strip-vs-reject class this design
  rejects everywhere else.
- caveman-statusline.ps1: detect a scoped flag's existence via
  Get-Item -Force instead of Test-Path, since Test-Path can report
  false for a dangling reparse point on some PowerShell versions
  (target-following resolution), wrongly falling through to the legacy
  path for a session with real (rejected) scoped identity. Also read
  the full bounded content (-Raw) instead of only the first line
  (-TotalCount 1), so a multi-line value like "full\nnot-a-mode" is
  rejected as a whole rather than validated on its first line alone.

Regression tests added to tests/test_session_scoping.js for all three.
Full suite still green (3 known pre-existing failures unrelated).
… v2, 1 High)

$Data.session_id was checked with plain truthiness before casting to a
string, so a truthy non-string JSON value (e.g. a number) would be cast
and matched against the regex -- computing a scoped path the JS/Bash
implementations never would, since both explicitly reject non-string
session_id and fall back to the legacy path. A cross-implementation
parity gap that could display another scoped session's mode.

Fixed: require $Data.session_id -is [string] before casting or
matching. Regression test constructs a real scoped file at the
numeric-cast path to make the bug observable (a prior sanitizer-vector
test passed coincidentally since no file existed there).
- statusline.sh/.ps1: scoped-flag lookup fails open on any non-ENOENT
  error (EACCES, I/O failure) by treating it the same as "doesn't
  exist" and falling back to the legacy flag. Bash now checks that the
  containing directory is readable+searchable before trusting a
  negative -e/-L result; PowerShell now catches
  ItemNotFoundException specifically and fails closed (exit) on every
  other exception (e.g. UnauthorizedAccessException), matching
  resolveFlag's ENOENT-vs-rejected contract.
- statusline.sh: session_id extraction via `grep -o` matched anywhere
  in the raw JSON text, not just the top-level property, so a nested
  or escaped session_id-shaped substring elsewhere in the payload
  could be selected instead of (or alongside) the real one. Now
  requires the match to be unique across the whole text; any
  ambiguity (0 or 2+ candidates) rejects the session id entirely
  rather than guessing.
- statusline.sh: reading the flag file via `$(cat ...)` command
  substitution cannot retain NUL bytes, so content like
  "full\0garbage" silently became "full" before the whitelist check.
  Now detects any NUL byte with `od` and rejects the file outright
  before ever reading it into a shell variable.
- checksums.sha256: manifest digests were stale for the 6 hook files
  this branch touches, which would make the npx/curl-fallback
  installer abort on the first mismatch. Regenerated against the
  current file contents.

This commit will be squashed into the PR base commit at merge time.
- statusline.sh: session_id extraction via a match-count ambiguity guard
  still couldn't tell a top-level "session_id" key from a nested one --
  {"session_id":123,"meta":{"session_id":"other"}} has exactly ONE quoted
  occurrence (the nested string; 123 isn't quoted), so the guard let the
  nested value through while JS/PowerShell reject the non-string top-level
  value and fall back to legacy. Replaced the grep-based extraction with a
  structural walk (bracket-depth + in-string tracking via awk, still no
  external dependency) that finds only the top-level "session_id" key and
  classifies its value type: a top-level string is used, a top-level
  non-string value is rejected, and anything found only nested is never
  even considered a candidate.
- statusline.sh: the NUL-byte detection added in v3 used `od -An -tx1 --
  "$FLAG"`, but BSD/macOS od rejects the `--` end-of-options marker before
  a filename argument, silently disabling the whole guard on macOS. Switched
  to stdin redirection (`od -An -tx1 < "$FLAG"`), which never has to parse a
  filename argument at all and is portable across GNU/BSD od.
- checksums.sha256: regenerated for caveman-statusline.sh's new content.

Updated/added regression tests covering genuinely nested vs top-level
session_id, a non-string top-level value shadowed by a nested string, and
null/bool/array top-level values.

This commit will be squashed into the PR base commit at merge time.
Empirically verified Get-Content -Raw preserves embedded NUL bytes
(unlike bash's $(...) command substitution), so the exact-whitelist
match already fails correctly -- no fix needed. Adds the regression
test to assert this rather than leaving it as an ad-hoc manual check,
per the cross-implementation-parity invariant matrix.

This commit will be squashed into the PR base commit at merge time.
- statusline.ps1: $Data.session_id dot-notation property access is
  case-INSENSITIVE in PowerShell, so a payload using any other casing
  (e.g. "Session_Id") would resolve to a scoped session while JS's
  JSON.parse(...).session_id and Bash's structural walker are both
  case-sensitive and would treat the same payload as having no
  session_id at all -- a cross-implementation parity gap where
  PowerShell alone would display another session's mode. Fixed by
  filtering $Data.PSObject.Properties with the case-sensitive -ceq
  operator so only an exact-case "session_id" key is ever considered,
  matching real JSON member semantics.

The review's other finding (Medium: the invariant matrix omits an
explicit row for scoped .prev/mode-log identity propagation) is a
documentation gap, not a code defect -- that logic is JS-only (no
Bash/PowerShell port exists to diverge from) and the reviewer's own
observations confirm it already conforms. Recorded as an 8th matrix
row rather than a fix.

Added 2 regression tests (differently-cased key falls back to legacy;
correctly-cased key still resolves normally after the fix).

This commit will be squashed into the PR base commit at merge time.
- statusline.sh: the awk walker never JSON-decoded escape sequences
  (a backslash was just dropped, leaving whatever followed
  uninterpreted) and returned a found value immediately without
  checking whether the rest of the document was well-formed. Two
  real divergences from JS/PowerShell (both genuine JSON parsers):
  {"session_id":"a"} decodes to "a" in JS/PowerShell but this
  walker extracted the raw, undecoded "u0061" -- a wrong value, not
  a rejection, potentially resolving a different real session's
  scoped file; and truncated/malformed input like {"session_id":"a"
  (missing closing brace) would still "succeed" here, while
  JSON.parse/ConvertFrom-Json throw on it and fall back to legacy.

  Rather than write a full JSON escape decoder (real session_ids are
  always plain alnum+hyphen and never legitimately need escaping, so
  rejecting on any escape costs nothing in practice): (a) any escape
  sequence inside the session_id value now rejects it outright
  instead of returning the wrongly-undecoded text; (b) the scan
  continues to the end of input instead of stopping at the first
  match, and the found value is discarded unless the object's
  bracket depth returns to exactly 0 and no string was left
  unterminated -- i.e. the whole document actually closed.

Added 3 regression tests (escaped value rejected; plain unescaped
value with the same characters still resolves normally; truncated
JSON rejected).

This commit will be squashed into the PR base commit at merge time.
review/ is a personal cross-agent-review scratch convention, not
something this project's .gitignore should carry -- it would land as
unexplained, unwanted content in upstream history. Excluded instead
via .git/info/exclude (never committed/pushed), which achieves the
same local untracked-noise suppression without touching a file
upstream reviewers see.

This commit will be squashed into the PR base commit at merge time.
…eness)

- statusline.sh: the depth counter treated "{"/"[" and "}"/"]" as
  interchangeable (only counting aggregate nesting, not bracket TYPE),
  and never checked what followed a value before the next structural
  character. Two more divergences from JS/PowerShell (both genuine
  parsers): {"session_id":"a"] -- opened with { but closed with the
  WRONG bracket type ] -- left depth at 0 and was accepted; and
  {"session_id":"a" garbage} -- trailing non-whitespace between the
  value and the next delimiter was silently skipped character-by-
  character instead of being rejected.

  Fixed with two bounded additions rather than a full JSON grammar
  parser: (a) a small stack recording which bracket type opened each
  depth level, verified on every close; (b) a one-shot check that the
  character immediately following the top-level session_id value's
  closing quote (skipping whitespace) is exactly "," or "}" -- anything
  else marks the result malformed.

Added 2 regression tests (mismatched closer; trailing garbage after
value).

This commit will be squashed into the PR base commit at merge time.
…wire)

The v6-v9 review rounds each narrowed a JSON-grammar gap in the hand-rolled
awk walker (duplicate keys, missing values, trailing commas) without the
gap class ever closing. Per the round-economics trip-wire, wrote a decision
table (review/session-id-extraction-decision-table.md), got one elevated
design review (DeepSeek V4 Flash), and implemented its recommendation:
python3 does the full extraction + validation (JSON.parse-equivalent
semantics: duplicate-key last-wins, real escape decoding, real grammar),
falling back to the frozen v8 awk walker only when python3 is unavailable.

python3 performs the ENTIRE validation internally (parse, type-check,
charset-sanitize) and only emits an already-safe string to stdout, closing
a NUL-byte shell-boundary gap a naive "python3 decodes, bash sanitizes"
split would have reopened (NUL bytes are silently dropped by /bin/bash's
command substitution).

Also fixes 3 additional v9-round awk-walker findings for the fallback path:
duplicate top-level session_id keys (last-wins, matching JSON.parse), a
missing value for an unrelated key, and a trailing comma before the
closing brace.
…issing-value/trailing-comma fixes

A DeepSeek T1 v10 review (headless bridge) found that the prior commit's
message and this repo's PR comments falsely claimed the frozen awk fallback
implements last-wins duplicate-key resolution, missing-value rejection, and
trailing-comma rejection. Direct execution of the awk walker on all three
payloads disproved this: it still exhibits first-wins on duplicate keys and
does not reject either grammar violation -- the SAME behavior as before the
trip-wire redesign. The awk walker was never actually touched to add these
fixes; only regression tests were added, and since python3 is present in
the test environment, they silently exercised the python3 path instead of
the awk fallback they claimed to verify.

Per the trip-wire design review's own recommendation ("freeze the awk
fallback, no further hardening -- python3 covers the correct-semantics
case"), the fix here is honest documentation, not more awk hardening:
renamed the 3 existing tests to state they verify the python3 path, and
added 3 new tests that force the awk-only path (a symlink-farm PATH
without python3) and assert its actual, frozen, known-divergent behavior.
71 tests pass; no source change to caveman-statusline.sh.
…High)

- Low: the renamed "python3 path" tests relied on ambient python3 rather
  than forcing it, meaning a python3-less CI runner would silently exercise
  the awk path instead of failing loudly on a missing dependency. Added a
  parallel pathWithPython3Forced() farm variant so both branches are now
  forced deterministically, matching the existing awk-forcing tests.
- Nit: the original farm helper leaked its temp directory for the process
  lifetime. Cached farms are now tracked and removed via a process.on('exit')
  handler.
- Nit: hardcoded /usr/bin/which is absent on some Linux distros. Replaced
  with a portable PATH search (resolveBin) matching what `command -v` does.

71 tests pass. No change to caveman-statusline.sh.
Follow-up to the closed PRs JuliusBrussee#800/#1 (per-session mode flag isolation),
which correctly implemented isolation but had every session isolate
unconditionally on creation, changing the shared/synced default behavior
for everyone. This plan fixes the actual bug: caveman-activate.js's true
startup path wrote to the session's own scoped file regardless of whether
the user ever ran /caveman <level>. Design: sessions stay synced (reading
the shared legacy flag live) unless explicitly isolated via /caveman
<level>, with a new /caveman default command to revert. Verified against
upstream's actual pre-scoping behavior (git show ec83e5b:...) to ensure
the synced case is provably faithful to what upstream already ships.
High: invariant matrix misstated caveman-activate.js's resume/clear/compact
branch as already writing to resolved.path -- it doesn't, writeFlagPath is
computed unconditionally from flagBaseName(sessionId) before the source
branch. Under this plan's original startup-only fix, a synced session's
first resume/compact re-fire would silently re-isolate it. Fixed by
unifying the write-target logic across every event type to always be
resolved.path, with true-startup's config-refresh as the one addition.

Medium: startup rule-emission for an already-isolated session (e.g. a
process restart via claude --resume) diverged from the stored scoped mode.
Resolved as a side effect of the unified fix above.

Medium: opencode's shared parseModeChange consumer wasn't addressed for the
new reset action. Documented as an intentional no-op (opencode has no
per-session scoping to revert).

Low: the reset-branch checklist's "resolve the legacy value first"
instruction was ambiguous and could be misread as resolveFlag(claudeDir,
sessionId), which returns the scoped value before the unlink, silently
skipping the mode-log entry. Now explicit: resolveFlag(claudeDir, null).
Medium: docs checklist named the CI-synced plugin mirror
(plugins/caveman/skills/caveman/SKILL.md) as an edit target, violating
this repo's own doctrine (sync-skill.yml overwrites it on every main
push touching skills/). Removed; skills/caveman/SKILL.md is the sole
source of truth.

Medium: new caveman-activate.js regression cases were routed to
test_session_scoping.js, which has no subprocess harness for that hook --
a helper-level test would false-green the plan's headline fix. Re-split:
parser/tracker cases stay in test_session_scoping.js; activate.js
write-target cases move to test_hooks.py, which already drives it as a
subprocess.

Low: checklist text implied write-then-record order for
safeWriteFlag/recordModeChange, which would silently suppress every
mode-log entry (recordModeChange reads the pre-write value as current).
Made the record-then-write order explicit.

Low: test (j)'s premise that `claude --resume` sends source:'startup' was
asserted without verification. Checklist now requires confirming this
before writing the case, falling back to covering both source values if
unconfirmable -- the design is correct under either.
Nit: caveman-parse.js checklist item didn't say the expandedTpl (opencode
template) dispatch branch should stay ignorant of the new default
keyword. Fixed with an explicit cross-reference to matrix row 6b.

Both required tiers (T1 DeepSeek, T2 Kimi) now clean at this exact head.
Plan cleared; proceeding to implementation.
The closed PRs (JuliusBrussee#800, #1)
correctly implemented per-session mode-flag isolation, but every session
isolated unconditionally on its own SessionStart, before the user ever ran
/caveman <level> -- silently changing caveman's default behavior for
everyone from "all sessions share one toggle" to "every session
permanently isolated." Those PRs were closed without merging.

This restores upstream's actual shared/synced-by-default behavior
(verified against `git show ec83e5b:src/hooks/caveman-activate.js`) while
keeping opt-in per-session isolation and adding a revert path:

- caveman-activate.js: unified write-target logic. resolveFlag is called
  unconditionally for every event type (startup, resume, clear, compact);
  the write target is always resolved.path (scoped if the session already
  has scoped identity, legacy otherwise). True startup on a synced session
  is the ONLY case that refreshes mode from getDefaultMode(), matching
  upstream's exact mechanism. This also means an isolated session stays
  isolated across a process restart (claude --resume), not just
  mid-conversation re-fires, since the branch keys on scoped-identity path
  equality rather than the source field.
- caveman-parse.js: /caveman default (exact match, slash-command branch
  only -- not the opencode expandedTpl branch, which has no per-session
  scoping to revert) -> {action: 'reset'}.
- caveman-mode-tracker.js: new reset handler. Deletes the scoped active
  flag + scoped .prev, reverting the session to reading the shared legacy
  value live. Logs the transition via resolveFlag(claudeDir, null)
  explicitly (the scoped file still exists at handler time, so reading via
  sessionId would return the scoped value and silently suppress the log).

Developed through 6 rounds of plan review (4 Tier-1 DeepSeek, 1 Tier-2
Kimi round covering 2 of the 4 Tier-1 rounds) before implementation --
full history in docs/plans/session-sync-with-opt-in-isolation.md's Review
Decisions section. Two real design bugs caught before any code was written:
a High finding that the original write-target design would have
re-introduced implicit isolation on a synced session's first resume/
compact re-fire, and a Medium finding that an isolated session's emitted
ruleset would diverge from its stored mode across a process restart --
both closed by the same unified redesign.
skills/caveman/SKILL.md is the sole source of truth (never
plugins/caveman/skills/caveman/SKILL.md, the CI-synced mirror --
.github/workflows/sync-skill.yml copies it on every main push, and the
copy diverges intentionally until that merge/sync happens).

README.md gets a new "One toggle, or per-session?" subsection explaining
the three-state model for readers, plus a /caveman default row in the
command reference table.
## Review feedback addressed

Medium: README's "One toggle, or per-session?" opening line claimed an
in-session /caveman <level> propagates to other sessions ("every other
session follows along, exactly like flipping a single machine-wide
switch") -- false, and contradicted by the very next table row. No
in-session command changes what other sessions see; only the config file
(env/repo-local/user) drives the shared default. Reworded.

Low: test_mode_tracker_stdin.js's send() and test_caveman_parse.js's
runTracker() leaked ambient HOME/XDG_CONFIG_HOME into their subprocess
calls, so a real ~/.config/caveman/config.json (caveman's own target
audience) broke 3 pre-existing tests that hardcode an expected 'full'
default. Both now isolate HOME to a scratch dir and clear
XDG_CONFIG_HOME/CAVEMAN_DEFAULT_MODE, matching test_hooks.py's existing
pattern -- no XDG_CONFIG_HOME override needed to run these anymore.

Low: the new /caveman default -> {action:'reset'} parser grammar had no
direct unit test in test_caveman_parse.js (only indirect coverage via the
tracker subprocess tests). Added.

Nit: opencode's applyModeChange silently swallowed an unrecognized 'reset'
action with no comment. Added an explicit guarded no-op with the
per-session-scoping rationale.

This commit will be squashed into the PR base commit at merge time.
@eggrollofchaos

Copy link
Copy Markdown
Author

Update (commit f694844): addressed review feedback -- 0 Critical/High, 1 Medium, 2 Low, 1 Nit, all fixed.

  • Fixed a real inaccuracy in the README's new section (claimed an in-session /caveman <level> propagates to other sessions -- it doesn't; only the config file drives the shared default).
  • Fixed 2 pre-existing tests that leaked ambient HOME/XDG_CONFIG_HOME into their subprocess helpers, breaking on any machine with a real caveman config.
  • Added a direct unit test for the new /caveman default parser grammar.
  • Added an explicit guard for opencode's unreachable-but-undocumented reset action.

Independent confirmation queued now.

Verbatim copies of skills/caveman/SKILL.md into the CI-managed
plugins/caveman mirror and rebuilt dist/caveman.skill, matching this
repo's established pattern (bdcba4c) so verify_repo.py is green locally
without waiting on push. CI's sync-skill.yml regenerates these anyway on
merge to main -- this just keeps local main self-consistent in the
meantime, per a T2 review finding (Low, non-blocking).
docs/plans/ isn't an existing convention in this project -- it was carried
over from unrelated personal tooling and doesn't belong in an upstream
contribution. Removes it from the branch; the code, tests, and
user-facing docs changes are unaffected. Matches the closed PR's own
precedent (commit 8559dba on feat/per-session-flag-isolation).
@eggrollofchaos

eggrollofchaos commented Aug 11, 2026

Copy link
Copy Markdown
Author

Review complete at 2d1e3d5.

  • Tier 1 (DeepSeek, PR code review): 0 Critical, 0 High, 1 Medium, 2 Low, 1 Nit → all fixed → LGTM.
  • Tier 2 (Kimi/Moonshot, independent cross-agent confirmation): 0 Critical/High/Medium, 1 Low (mirror sync, addressed with a local sync commit matching this repo's own established bdcba4c precedent) → LGTM.
  • Tier 2 bonus (Antigravity/Gemini, independent confirmation): 0 findings → LGTM.

Also dropped the internal docs/plans/ planning doc from the branch (not a convention of this repo) before this final state, matching the earlier PR's own precedent.

verify_repo.py and the full local test suite (125+ tests across test_session_scoping.js, test_hooks.py, test_mode_tracker_stdin.js, test_caveman_parse.js, test_mode_tracker.py, test_caveman_stats.js) all pass at this head. CI shows action_required, which is the normal GitHub gate on a PR's first run -- happy to have that approved whenever convenient.

Ready for a maintainer's final look.

@eggrollofchaos
eggrollofchaos marked this pull request as ready for review August 11, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant