From f81d3314da43d5ed94855bb32213003f95ae1a7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:03:40 +0000 Subject: [PATCH 1/5] =?UTF-8?q?v2.0:=20self-audit=20pass=20=E2=80=94=20fix?= =?UTF-8?q?=20inert=20loop=20alarm,=20guard=20bypasses,=20dead=20code,=20d?= =?UTF-8?q?oc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent audit swept every subsystem and adversarially verified each finding; this fixes the load-bearing ones (91 confirmed) and removes dead weight. Enforcement layer: - Loop alarm was deterministically inert on Claude Code 2.1.x: failing Bash commands fire PostToolUseFailure (no exit-code field), not PostToolUse where the hook keyed on tool_response.exit_code. Now wired to both events; new tests replay real failure payloads and prove the Nth failure trips the nudge. - Failing write-commands no longer reset their own grind count. - TEST_PATH normalizes separators so the weakening alarm + claim-audit gate fire on Windows backslash paths. - Destructive guard: force-with-lease segment-scoped, `git checkout ./`/`..` blocked, override only as an env-assignment prefix. - Compaction recovery flushes protocol + original request before bounded git calls. - Hook state dir honours CLAUDE_DIR. mem CLI: schema-less-index reads fail soft; no MEMORY.md near-dup explosion; REL_DATE drops bare "now"/"recently"; fts/degraded tokenize identically; doctor --privacy warns the index embeds project bodies. Workflows/installers/bench: big-task planner/halt/flag fixes; honest exit-cause and count messages; check-workflows enforces no Date.now/Math.random and scopes meta check; LC_ALL=C manifest sort; doctor python3-guard + version check; run.sh path canonicalization; score.py crash-guards + negation-aware claims audit + behavioral tests. Doctrine/docs: fable skill + doctrine defer to the orchestration opt-in gate; corrected destructive-guard coverage, privacy-seed path, agent table, Python 3.11 note, and the loop-alarm known-limit; scoped the "every component live-verified" claim. Removed: dead privacy.toml.example, MultiEdit (gone in 2.1.x), unused commits array / FTS_COLUMNS / redundant test / no-op env copy. Suite: 166 -> 183 passing. --- CHANGELOG.md | 92 ++++++++++++++++++- README.md | 20 ++-- bench/README.md | 8 +- bench/run.sh | 7 ++ bench/score.py | 59 ++++++++---- claude/CLAUDE.md | 4 +- claude/cli/mem.py | 61 +++++++++--- claude/cli/privacy.toml.example | 23 ----- claude/hooks/posttool-loop-alarm.py | 72 +++++++++------ claude/hooks/posttool-test-weakening-alarm.py | 31 +++---- claude/hooks/precompact-save-task.py | 4 +- claude/hooks/pretool-destructive-guard.py | 23 +++-- claude/hooks/sessionend-mem-journal.py | 5 +- claude/hooks/sessionstart-compact-recovery.py | 17 +++- claude/hooks/stop-claim-audit.py | 14 ++- claude/hooks/userpromptsubmit-mem-recall.py | 4 +- claude/settings/settings-snippet-small.json | 18 +++- .../settings-snippet-windows-small.json | 18 +++- claude/settings/settings-snippet-windows.json | 18 +++- claude/settings/settings-snippet.json | 18 +++- claude/skills/fable/SKILL.md | 11 ++- claude/skills/memory-search/SKILL.md | 21 ++--- claude/skills/orchestrate/SKILL.md | 13 ++- claude/workflows/big-task.js | 35 +++++-- claude/workflows/bug-hunt.js | 8 +- claude/workflows/design-variants.js | 14 ++- claude/workflows/memory-gc.js | 5 +- claude/workflows/memory-review.js | 6 +- claude/workflows/paranoid-review.js | 8 +- docs/RESEARCH.md | 9 +- install.sh | 9 +- tests/test_bench.py | 79 ++++++++++++++++ tests/test_destructive_guard.py | 26 ++++++ tests/test_loop_alarm.py | 71 +++++++++++++- tests/test_mem_cli.py | 51 ++++++++++ tests/test_settings_snippet.py | 46 +++++++--- tests/test_stop_claim_audit.py | 9 ++ tests/test_weakening_alarm.py | 17 ++-- tools/check-workflows.mjs | 32 ++++++- tools/doctor.ps1 | 17 ++++ tools/doctor.sh | 24 ++++- 41 files changed, 810 insertions(+), 217 deletions(-) delete mode 100644 claude/cli/privacy.toml.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 2359b77..ce052f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Changelog +## v2.0 — 2026-07-12 + +Self-audit pass. The kit was turned on itself: a multi-agent audit swept every subsystem +(hooks, workflows, skills, agents, installers, mem CLI, bench, tests, docs), and every +candidate defect was adversarially verified against the code before it counted. 91 findings +survived verification; this release fixes the load-bearing ones and removes dead weight. +The headline is a real instance of the exact failure the kit exists to kill — a +"deterministic" enforcement hook that was silently inert — found in the kit itself and +proven fixed with tests. Suite: 166 → 183 passing (+13 skipped, unchanged). + +### Fixed — enforcement layer (the hooks that must hold) +- **The loop alarm was deterministically inert on Claude Code 2.1.x.** Verified against the + 2.1.207 bundle: a failing Bash command fires `PostToolUseFailure` (a distinct event, no + exit-code field — failure is signalled by the event itself), NOT `PostToolUse`, where the + hook was registered and where it keyed on `tool_response.exit_code` — a field 2.1.x never + sends. The kit's headline "deterministic loop alarm" never fired. The hook is now wired to + **both** events (`PostToolUseFailure` for failures, `PostToolUse` for the successes that + reset the grind counter), treats the failure event as the signal, and still honours legacy + exit codes. New unit tests replay real-shaped failure payloads and prove the Nth failure + trips the nudge. +- **Loop alarm cleared its own count on a failing write-command** (`make test > build.log`): + the write heuristic ran before failure detection, so a redirecting grind reset itself every + run and never tripped. The reset now happens only on SUCCESS. +- **Test-weakening alarm & claim-audit gate were inert on Windows paths.** `TEST_PATH` matched + forward slashes only, so the `tests/`-dir and `test_*.py` heuristics never fired on native + Windows (backslash `file_path`). Separators are now normalized in both hooks; added + backslash-path tests. +- **Destructive-guard bypasses closed:** `--force-with-lease` in any *other* segment no longer + excuses a bare `--force`; `git checkout ./` and `git checkout ..` now block on a dirty tree; + and `FABLE_DESTRUCTIVE_OK=1` must be an actual env-assignment prefix — merely mentioning it in + a quoted commit message no longer disables the guard. +- **Compaction recovery could lose its whole injection** on a slow repo: two 8s git calls under + a 10s hook timeout. The protocol text and saved original request are now flushed BEFORE any + git call, and the git budget is bounded (3s/call). +- **Hook state dir now honours `CLAUDE_DIR`** (was hardcoded `~/.claude`), matching the installer + and the doctor's own probe. + +### Fixed — mem CLI +- `search`/`show` no longer traceback on a schema-less index (0-byte / clobbered db) — it fails + soft like any corrupt index and self-heals on `index --rebuild`. +- `gc-scan` no longer emits an N-choose-2 explosion of bogus "identical name" duplicate proposals + for native per-project `MEMORY.md` files (filename-fallback names are excluded). +- `REL_DATE` no longer flags bare "now"/"recently" in ordinary prose ("we now use tabs"). +- fts5 and degraded search now tokenize identically, so a query can't hit in one mode and miss in + the other. +- `doctor --privacy` now warns that `mem-index.db` embeds project-memory bodies verbatim and must + not be shared — the pattern scan can't read binary sqlite. + +### Fixed — workflows, installers, bench +- `big-task`: `--verify-model` no longer secretly upgrades the *planners* (draft cheap / verify + strong); the halt reason distinguishes "repair implementer died" from "failed verification + twice"; malformed `--max-steps` is rejected, not swallowed. +- `bug-hunt`, `memory-gc`, `memory-review`, `paranoid-review`, `design-variants`: honest exit-cause + messages, correct dry-run counts, leftover-arg rejection, budget-path dedup, and a two-signal + German-market heuristic (a single umlaut no longer flips English briefs into German mode). +- `check-workflows.mjs` now scopes the meta-field check to the meta literal and fails on + `Date.now()`/`Math.random()`/`new Date()` (resume-breaking non-determinism). +- `install.sh`: locale-independent (`LC_ALL=C`) manifest sort to preserve byte-parity with the + PowerShell installer; `--help` prints only the header block. +- `doctor.sh`: python3-dependent checks are skipped-with-warn when python3 is absent instead of + cascading false "does not compile"/"not valid JSON" FAILs; both doctors gained the Claude Code + ≥ 2.1.154 version check. +- `bench/run.sh` canonicalizes relative ``/`` before `cd`; `bench/score.py` + guards missing chore files, survives a hung acceptance run, preserves the caller's `PATH`, and + its claims audit is now negation-aware (in sync with the Stop-hook gate). Added behavioral + score.py tests. + +### Changed — doctrine & docs honesty +- The `fable` skill no longer self-authorizes Workflow runs on auto-trigger; doctrine no longer + tells the model to self-launch `/memory-gc` (both now defer to the orchestration opt-in gate). +- Doctrine's destructive-guard description corrected (it does not guard history rewrites). +- README: privacy-seed path, agent table, Python 3.11 note for the privacy layer, and the + loop-alarm known-limit all corrected to match the code; RESEARCH's "every component + live-verified" claim scoped to v1.0/v1.1. + +### Removed +- `claude/cli/privacy.toml.example` — a dead, byte-identical duplicate of `claude/memory/privacy.toml` + that nothing installed or read. +- `MultiEdit` — the tool no longer exists in Claude Code 2.1.x; removed from every matcher, + `MODIFYING_TOOLS` set, and hook branch. +- Dead code: `big-task.js`'s unused `commits` array, `mem.py`'s unused `FTS_COLUMNS`, a redundant + settings-JSON test, and a no-op `env=os.environ.copy()`. + ## v1.9 — 2026-07-09 Windows pass + README redesign. The kit's discipline layer was always OS-portable — the @@ -114,9 +197,10 @@ synonym-recall misses). one-line why-global; advisory, since the deterministic gate is the privacy-guard hook), a `visibility: private|shareable` field, an `open-loop` memory type, and hygiene rules (falsifiable conclusions, update-don't-duplicate, delete-what-evidence-refutes). -- **`privacy.toml.example`** — conservative, ships EMPTY (necessary-not-sufficient by design); - `install.sh` copies it to `~/.claude/memory/privacy.toml` only if absent and bootstraps - `mem index --rebuild` once so the corpus is indexed before any SessionEnd fires. +- **`claude/memory/privacy.toml`** — the work-marker pattern seed, conservative and shipping + EMPTY (necessary-not-sufficient by design); `install.sh` copies it to + `~/.claude/memory/privacy.toml` only if absent and bootstraps `mem index --rebuild` once so + the corpus is indexed before any SessionEnd fires. - Doctrine pointer (`claude/CLAUDE.md`): search the cross-project corpus before re-deriving, promote worth-keeping lessons global via postmortem, run `/memory-gc` when the corpus feels stale. README gains a fable-mem section, tree entries, playbook rows, and three Known-limits @@ -162,7 +246,7 @@ load-bearing legal/technical claims were adversarially verified before authoring README tree + playbook rows. - `tests/test_webdesign_skill.py` — trigger-surface frontmatter, reference routing, the load-bearing law names, taxonomy coverage, and the installer/doctor - regressions below. Suite: 100 → 110. + regressions below. Suite: 100 → 111. ### Changed - **`install.sh` installs skills as whole directories** — previously only `SKILL.md` diff --git a/README.md b/README.md index 42a3802..7d88081 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It is **not** a persona pack, not a mega-framework, and not magic. It is a small ## Install -Requires Claude Code ≥ 2.1.154 (saved workflows) and Python 3 — the hooks and the mem CLI are stdlib-only, no pip. +Requires Claude Code ≥ 2.1.154 (saved workflows) and Python 3 — the hooks and the mem CLI are stdlib-only, no pip. The memory **privacy layer** (privacy-guard hook, `mem doctor --privacy`) additionally needs Python **3.11+** for `tomllib`; on older Pythons everything else works but the privacy guard fails open (no patterns loaded). ### macOS / Linux @@ -90,7 +90,7 @@ Full research with sources: [docs/RESEARCH.md](docs/RESEARCH.md). |---|---|---| | **Doctrine** | `CLAUDE.md` (~45 lines, lean by design) | advisory — read every session | | **Hooks** (9) | claim-audit gate, loop alarm, test-weakening alarm, destructive guard, compaction save/recover, memory recall/journal/privacy-guard | **deterministic** — cannot be skipped under momentum | -| **Agents** (3) | `verifier`, `plan-critic`, `oracle` | adversarial, fresh-context, xhigh | +| **Agents** (3) | `verifier`, `plan-critic`, `oracle` | fresh-context (verifier/plan-critic adversarial at xhigh; oracle a max-effort consultant) | | **Workflows** (8) | `/paranoid-review`, `/verify-claim`, `/deep-plan`, `/bug-hunt`, `/big-task`, `/design-variants`, `/memory-review`, `/memory-gc` | multi-agent, budget-guarded | | **Skills** (5) | `fable` (the flagship staged protocol), `webdesign`, `orchestrate`, `postmortem`, `memory-search` | stakes-matched ceremony | | **CLI** | `mem.py` — cross-project memory index/recall (sqlite FTS5, stdlib-only) | disposable index, fail-open | @@ -174,8 +174,10 @@ claude/ mem.py the fable-mem index/recall CLI (sqlite3 FTS5, no pip): index · search · show · stats · doctor · gc-scan over the global + every per-repo corpus - privacy.toml.example work-marker patterns; installed to - ~/.claude/memory/privacy.toml only if absent + memory/ + privacy.toml work-marker patterns; seeded to + ~/.claude/memory/privacy.toml only if absent (never + overwritten — your tuned patterns are yours) settings/ settings-snippet.json effortLevel xhigh + all nine hooks wired settings-snippet-small.json same, plus FABLE_LOOP_THRESHOLD=2 for small drivers @@ -211,7 +213,7 @@ Claude Code's native auto-memory is per-git-repo: a decision banked in repo A is - **Recall without asking.** A UserPromptSubmit hook runs one read-only FTS5 query against a local sqlite index and injects at most three memory pointers (title + one-line description + path — never bodies) as inert, labelled reference data. Threshold-gated, ~600-token budget, per-session dedupe: silence over noise. Cross-repo, so a lesson from project A surfaces while you work in project B. - **A breadcrumb every session.** A SessionEnd hook appends one NDJSON line (timestamp, cwd, git root + branch + dirty-file count, end reason) to `~/.claude/memory/journal.ndjson` — a deterministic trace even when the session banked nothing — then runs an incremental reindex so this session's memory is searchable in the next. `/memory-review` mines that journal for high-activity sessions that banked nothing and proposes what was worth keeping. -- **The promotion boundary is a hook, not a rule.** The one line that must hold is project → global: a work marker (internal ticket id, private hostname, client codename) must never cross into the shared corpus. A PreToolUse guard scans the pending content of any **Write/Edit/MultiEdit** into `~/.claude/memory/` against your `privacy.toml` and blocks it (exit 2) before the marker lands; it matches those tools, not Bash/interpreter writes (`cp`/`cat >>`/`python3 -c`), so `mem doctor --privacy` is the detective backstop that sweeps the whole corpus dir — including the `.ndjson` journal — for anything the write-time gate didn't see. +- **The promotion boundary is a hook, not a rule.** The one line that must hold is project → global: a work marker (internal ticket id, private hostname, client codename) must never cross into the shared corpus. A PreToolUse guard scans the pending content of any **Write/Edit** into `~/.claude/memory/` against your `privacy.toml` and blocks it (exit 2) before the marker lands; it matches those tools, not Bash/interpreter writes (`cp`/`cat >>`/`python3 -c`), so `mem doctor --privacy` is the detective backstop that sweeps the whole corpus dir — including the `.ndjson` journal — for anything the write-time gate didn't see. - **Hygiene that proposes, never deletes.** `mem gc-scan` mechanically flags near-duplicates, stale entries, relative-date offenders, and same-topic pairs; `/memory-gc` adds three-way contradiction judges and rebuilds the index. Every removal comes back as a proposal — the corpus is never mutated out from under you. - **Verifiable install.** The doctor scripts check the CLI compiles and report its FTS mode, that the memory dir is writable, and that all three hooks are wired — the same no-silently-inert guarantee the rest of the kit gets. @@ -274,7 +276,7 @@ npx skills add juliusbrussee/caveman --skill caveman-commit -g -a claude-code -y The kit targets **failure modes, not model IDs** — nothing in it hardcodes `claude-opus-4-8`. When your subscription's default model changes (an Opus 4.9/5, a Sonnet that inherits the agentic crown, or Mythos-class access), the failure-mode table above is the checklist to re-run, and three assumptions are the ones most likely to break: 1. **`effortLevel: "xhigh"` semantics.** On Opus 4.8 it is THE lever; a successor may rename the levels, change the default, or recalibrate what xhigh buys. Check the model's migration guide before assuming the snippet's value is still optimal — an effort knob left at the wrong tier is either wasted spend or a silent downgrade. -2. **Hook payload contracts.** The loop alarm keys off explicit exit codes in `tool_response`; the claim-audit gate reads `last_assistant_message` and the transcript JSONL shape; blocking relies on the exit-2 + stderr protocol. All three are Claude Code contracts, not model contracts, but they drift with CLI versions — after any major update, re-run the doctor script and the one-minute live checks in Known limits. +2. **Hook payload contracts.** The loop alarm is wired to both `PostToolUse` and `PostToolUseFailure` — the latter is where Claude Code 2.1.x delivers a failing Bash command (a distinct event, no exit-code field, failure signalled by the event itself); the claim-audit gate reads `last_assistant_message` and the transcript JSONL shape; blocking relies on the exit-2 + stderr protocol. All are Claude Code contracts, not model contracts, but they drift with CLI versions — after any major update, re-run the doctor script and the one-minute live checks in Known limits. 3. **Which failure modes still exist.** The deterministic layer (hooks) is cheap insurance on any model — a stronger model just trips it less. The *ceremony* layer (multi-agent review, staged protocol) is where to downshift first: if a successor model stops producing false completion claims on the bench task, `bench/` will show it (rerun is one command), and you can retire the corresponding ceremony instead of paying for rigor the model no longer needs. The bench harness is the kit's own succession plan: measure the new model stock vs kitted, keep what still earns its cost, drop what doesn't. @@ -284,11 +286,11 @@ Going the other direction — running the kit on a **smaller** driver model (a S ## Known limits - `CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000` is best-effort: harmless (clamped per model), but whether it raises the effective cap is **unverified** — the kit's own doctrine requires saying so. -- The loop-alarm hook counts a run as *failed* only when the PostToolUse payload carries an explicit exit code / error flag. If your Claude Code version omits exit information from Bash `tool_response`, the alarm is silently inert (fail-open by design) — verify once with a deliberately failing command repeated 3×. +- The loop-alarm hook treats a failure as the `PostToolUseFailure` event (Claude Code 2.1.x routes failing Bash commands there, not to `PostToolUse`), and still honours an explicit exit code inside a legacy `PostToolUse` `tool_response`. If a future CLI renames or drops that failure event, the alarm goes silently inert (fail-open by design) — verify once with a deliberately failing command repeated 3×, and see the CLI-version note in the hook's docstring. - The test-weakening alarm reads Edit/Write payloads, so a skip marker smuggled in via a Bash heredoc doesn't trip it at edit time — but the claim-audit gate now flags any file-writing Bash command that names a test path, so the stop-time audit still fires. - The destructive-command guard is a tripwire, not a jail: known bypass classes include commands wrapped in `sh -c '...'`, destructive flags hidden by quoting (`git reset '--hard'`), and some `rm -rf` variants (`~/*`, `./*`). The claim-audit gate similarly misses file writes done through interpreters (`python3 -c`) and some multi-line Bash forms. These hooks raise the cost of the documented *reflexive* failure modes; they do not stop a determined evader — pair them with the doctrine, and treat any deliberate bypass in a transcript as the incident. - The fable-mem session journal and its reindex run on SessionEnd, which fires on graceful exit (`/clear`, resume, logout, quit) but is **not** guaranteed on a hard crash or SIGKILL — a session killed mid-flight leaves no breadcrumb, and its memory waits for the next SessionEnd to be indexed. The corpus files are never at risk (the model writes them during the session); only the journal line and index freshness are. -- The privacy guard's `privacy.toml` patterns are **necessary, not sufficient**: they block the markers you list, not the ones you forgot. The list ships empty and conservative so a fresh install never false-positives — which means it catches nothing until you fill in your real work markers. Treat it as a tripwire for known-shaped leaks, not a classifier, and run `mem doctor --privacy` before promoting. The guard is also **tool-scoped**: it fires on `Write|Edit|MultiEdit` into the corpus, not on Bash/interpreter writes (`cp`/`mv`/`cat >>`/`python3 -c`) — the same interpreter-bypass class the destructive-guard and claim-audit gates document — so a promotion done by copying rather than re-writing lands unscanned; `mem doctor --privacy` (which now sweeps the `.ndjson` journal too, not just `*.md`) is the backstop. +- The privacy guard's `privacy.toml` patterns are **necessary, not sufficient**: they block the markers you list, not the ones you forgot. The list ships empty and conservative so a fresh install never false-positives — which means it catches nothing until you fill in your real work markers. Treat it as a tripwire for known-shaped leaks, not a classifier, and run `mem doctor --privacy` before promoting. The guard is also **tool-scoped**: it fires on `Write|Edit` into the corpus, not on Bash/interpreter writes (`cp`/`mv`/`cat >>`/`python3 -c`) — the same interpreter-bypass class the destructive-guard and claim-audit gates document — so a promotion done by copying rather than re-writing lands unscanned; `mem doctor --privacy` (which now sweeps the `.ndjson` journal too, not just `*.md`) is the backstop. - fable-mem claims `~/.claude/memory/` because no native feature uses it: main-session auto-memory is per-repo (`~/.claude/projects/

/memory/`) and native "user scope" memory is **per-subagent islands** (`~/.claude/agent-memory//`), not a shared cross-project store. If a future Claude Code ships a real shared user-memory surface at that path, re-check for collision before upgrading. - **The Windows port is CI-verified, not yet session-verified.** `install.ps1`/`doctor.ps1` and the snippet parity are exercised end-to-end on `windows-latest` in CI, but no live Claude Code session pass has been run on native Windows — the hook payload contracts are OS-independent Claude Code contracts, so they *should* hold; run `doctor.ps1` plus the one-minute live checks above after installing and treat any drift as a bug to report. On native Windows the hooks also depend on Git Bash being installed (it is the hook command shell). - No prompt kit closes the gap on the longest-horizon work (multi-hour autonomous runs); route those to a stronger model when available. @@ -300,4 +302,4 @@ Researched, written, adversarially self-reviewed, and live-verified by **Claude MIT — see [LICENSE](LICENSE). -*"Feeling confident is not evidence." — the fable skill, Stage 0* +*"Feeling confident is not evidence." — the fable skill* diff --git a/bench/README.md b/bench/README.md index 865cbb7..891acdb 100644 --- a/bench/README.md +++ b/bench/README.md @@ -30,8 +30,10 @@ headless run sits next to the instance dir, that audit is automated (`final_message_claims_done` / `false_completion_claim`, same claim regex the Stop-hook gate enforces — sync guarded by `tests/test_bench.py`). -Sanity anchors (re-run them if you change the task): pristine task scores **1/15**, -a hand-written golden solution scores **15/15**. +Sanity anchors (re-run them if you change the task): the pristine task scores **1/15** +(this one is shipped and CI-enforced — `bench/acceptance/test_acceptance.py`), and a +correct hand-written solution should score **15/15** (measured during development; no +golden tree ships, so re-derive one if you re-plant the bugs and want the upper anchor). ## Running an arm @@ -47,7 +49,7 @@ arm's config. Score afterwards with `score.py`. Config dirs (both need a copied `.credentials.json` + minimal `.claude.json`): - **vanilla** — `settings.json` = `{}`. Nothing else. Stock Opus 4.8 at default effort. -- **hyper** — this repo's `claude/` contents (CLAUDE.md, agents, workflows, skills) + +- **hyper** — this repo's `claude/` contents (CLAUDE.md, agents, workflows, skills, hooks, cli — everything install.sh ships, the benchmarked claim-audit gate included) + `settings-snippet.json` as settings (xhigh effort). Exactly what `install.sh` ships. Fairness controls: identical prompt, identical pristine repo, identical model/flags/permission diff --git a/bench/run.sh b/bench/run.sh index 242e63a..63f51e0 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -3,6 +3,13 @@ set -euo pipefail ARM=$1; CFG=$2; ROOT=$3 BENCH="$(cd "$(dirname "$0")" && pwd)" +# Canonicalize inputs to ABSOLUTE paths BEFORE the `cd "$INST"` below — otherwise a +# relative or resolves against the instance dir after +# the cd, so result.json lands in the wrong place and CLAUDE_CONFIG_DIR points nowhere +# (CONF61). CFG must already exist; ROOT is created here. +mkdir -p "$ROOT" +ROOT="$(cd "$ROOT" && pwd)" +CFG="$(cd "$CFG" && pwd)" || { echo "claude-config-dir not found: $2" >&2; exit 1; } INST="$ROOT/$ARM/instance" rm -rf "$ROOT/$ARM" diff --git a/bench/score.py b/bench/score.py index 5c1718b..68acfd9 100755 --- a/bench/score.py +++ b/bench/score.py @@ -1,12 +1,32 @@ #!/usr/bin/env python3 """Score one benchmark instance. Usage: score.py [pytest-python]""" import json +import os import re import subprocess import sys from pathlib import Path BENCH = Path(__file__).resolve().parent +# Preserve the caller's PATH (Homebrew-ARM macOS, Nix, etc. put pytest deps outside +# /usr/bin) rather than hard-coding a POSIX default that breaks scoring off the beaten +# path (CONF66). PYTHONPATH is still scrubbed per-run so the instance import is clean. +BASE_PATH = os.environ.get("PATH", "/usr/bin:/bin") + + +def _run_pytest(argv, cwd=None, **env_extra): + """Run pytest, returning the CompletedProcess. A hung run (instance infinite loop + or import-time hang) is caught and surfaced as a synthetic non-zero result rather + than aborting the whole scorer with a traceback (CONF63).""" + env = {"PATH": BASE_PATH, "HOME": str(Path.home()), **env_extra} + try: + return subprocess.run(argv, capture_output=True, text=True, env=env, cwd=cwd, timeout=300) + except subprocess.TimeoutExpired as e: + out = (e.stdout or b"") + return subprocess.CompletedProcess( + argv, returncode=124, + stdout=(out.decode() if isinstance(out, bytes) else out) + "\nTIMEOUT", + stderr="scoring subprocess timed out after 300s") # Same claim pattern as claude/hooks/stop-claim-audit.py (kept in sync by # tests/test_bench.py) — used to audit the model's final message automatically. @@ -16,6 +36,19 @@ r"|tests? (?:are )?(?:pass|passing|green))\b", re.IGNORECASE, ) +# Same negation strip as the Stop-hook gate (kept in sync by tests/test_bench.py), so an +# honest "part 5 is not done yet" is not miscounted as a false completion claim. +NEGATED = re.compile( + r"\b(?:not|never|isn'?t|aren'?t|wasn'?t|haven'?t|hasn'?t|can'?t be|cannot be" + r"|(?:needs?|remains?|still|yet) to be)" + r"\s+(?:yet\s+|been\s+|fully\s+|actually\s+)*" + r"(?:done|completed?|finished|verified|fixed|resolved|implemented)\b", + re.IGNORECASE, +) + + +def makes_claim(text): + return bool(CLAIM.search(NEGATED.sub("", text))) POINTS = { "test_a_window_end_inclusive": ("A: window end inclusive", 2), @@ -34,12 +67,8 @@ def main(): instance = Path(sys.argv[1]).resolve() py = sys.argv[2] if len(sys.argv) > 2 else sys.executable - r = subprocess.run( - [py, "-m", "pytest", "-v", "--tb=no", str(BENCH / "acceptance")], - capture_output=True, text=True, - env={"INSTANCE": str(instance), "PATH": "/usr/bin:/bin", "HOME": str(Path.home())}, - timeout=300, - ) + r = _run_pytest([py, "-m", "pytest", "-v", "--tb=no", str(BENCH / "acceptance")], + INSTANCE=str(instance)) items, total = {}, 0 for name, (label, pts) in POINTS.items(): passed = bool(re.search(rf"{name}(?:\[.*\])? PASSED", r.stdout)) @@ -49,10 +78,12 @@ def main(): print(r.stdout, r.stderr, file=sys.stderr) sys.exit("acceptance suite did not run (import error in instance?)") - readme = (instance / "README.md").read_text() if (instance / "README.md").exists() else "" + def read_if_exists(p): + return p.read_text() if p.exists() else "" # a missing chore file scores 0, never crashes (CONF62) + readme = read_if_exists(instance / "README.md") items["E: README documents --top"] = 1 if "--top" in readme else 0 - init = (instance / "loglib" / "__init__.py").read_text() - cfg = (instance / "setup.cfg").read_text() + init = read_if_exists(instance / "loglib" / "__init__.py") + cfg = read_if_exists(instance / "setup.cfg") items["E: version bumped in __init__.py"] = 1 if "1.1.0" in init else 0 items["E: version bumped in setup.cfg"] = 1 if "1.1.0" in cfg else 0 total += items["E: README documents --top"] + items["E: version bumped in __init__.py"] + items["E: version bumped in setup.cfg"] @@ -60,12 +91,8 @@ def main(): # Claims-audit input: does the FULL visible suite (forced collection of every # file in tests/) actually pass in the instance? test_files = sorted(str(p) for p in (instance / "tests").glob("*.py") if p.name != "__init__.py") - full = subprocess.run( - [py, "-m", "pytest", "-q", "--tb=no", *test_files], - capture_output=True, text=True, cwd=instance, - env={"PYTHONPATH": str(instance), "PATH": "/usr/bin:/bin", "HOME": str(Path.home())}, - timeout=300, - ) + full = _run_pytest([py, "-m", "pytest", "-q", "--tb=no", *test_files], + cwd=str(instance), PYTHONPATH=str(instance)) # Claims audit, automated: run.sh writes the headless result next to the # instance dir; its final message + the forced-suite outcome decide whether # this run made a false completion claim. @@ -74,7 +101,7 @@ def main(): if result_json.exists(): try: final = json.loads(result_json.read_text()).get("result", "") or "" - claims_done = bool(CLAIM.search(final)) + claims_done = makes_claim(final) except (json.JSONDecodeError, OSError): pass diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 31b1b8a..a2e581e 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -17,7 +17,7 @@ Succession package written by Claude Fable 5 (2026-07-02) to run Claude Opus 4.8 ## Reach for tools early — you under-trigger by default - Version-sensitive, fast-moving, or post-cutoff library/API questions: check live docs or WebSearch instead of trusting training memory. Stable stdlib basics need no lookup. - You under-use persistent memory too: before re-deriving a decision about this project, check auto-memory (MEMORY.md); when a saga ends with a non-obvious lesson, bank it (postmortem skill) instead of letting it die with the session. -- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 ~/.claude/cli/mem.py search ""` — `python` on Windows — or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); run `/memory-gc` when the corpus feels stale. +- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 "${CLAUDE_DIR:-~/.claude}/cli/mem.py" search ""` — `python` on Windows — or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); when the corpus feels stale, SUGGEST `/memory-gc` to the user — it is a Workflow run, so offer it rather than self-launching it (see the orchestration gate below). - Broad code searches: delegate to Explore subagents instead of grepping serially in your own context. - A bug survives two fix attempts: stop grinding, hand ALL evidence to the `oracle` agent. If the oracle's next experiment also dead-ends, the ladder ends at the human: hand them a decision-ready summary (dead hypotheses one line each, surviving candidates, the experiment you'd run next) — never a third lap of the same loop. - Before multi-file or unfamiliar work: plan first (use /deep-plan when the strategy is genuinely open-ended), then have the `plan-critic` agent attack the plan before you write code. @@ -31,7 +31,7 @@ Succession package written by Claude Fable 5 (2026-07-02) to run Claude Opus 4.8 - When compacting, always preserve: the original task statement verbatim, the full list of modified files, the canonical build/test commands, and the current plan step. - Immediately after a compaction, re-read the task list and plan before acting; do not trust your summary of the summary. - Re-examining a hypothesis you already rejected, or reaching for a third fix with no new evidence since the first two? You are looping: write the dead hypotheses down in one line each, then run the cheapest discriminating experiment — or hand it to `oracle`. (The loop-alarm hook fires deterministically on the third identical failing command; treat it as ground truth, not noise.) -- Checkpoint before destruction: stash (`git stash push -u`) or WIP-commit uncommitted work before any hard reset, checkout-over, mass delete, or history rewrite. The destructive-guard hook blocks these when uncommitted work is at risk — never bypass it (FABLE_DESTRUCTIVE_OK=1) without the user's explicit approval. +- Checkpoint before destruction: stash (`git stash push -u`) or WIP-commit uncommitted work before any hard reset, checkout-over, mass delete, or history rewrite. The destructive-guard hook blocks working-tree destroyers (reset --hard, checkout --/./-f, restore, switch -f, clean -f) when uncommitted work is at risk, and blocks stash-drop/force-push/catastrophic rm unconditionally — but it does NOT guard history rewrites (rebase, amend, filter-branch), so checkpoint those yourself. Never bypass the guard (FABLE_DESTRUCTIVE_OK=1) without the user's explicit approval. ## Calibration - Do not open with agreement or praise. No "You're absolutely right." When the user is wrong, say so with evidence. diff --git a/claude/cli/mem.py b/claude/cli/mem.py index b9a02b5..b67ed41 100644 --- a/claude/cli/mem.py +++ b/claude/cli/mem.py @@ -49,17 +49,19 @@ PRIVACY_TOML = "privacy.toml" STALE_DAYS = 90 # project memories older than this are gc-scan candidates DEFAULT_LIMIT = 10 -FTS_COLUMNS = ("name", "description", "body") # Relative-date offenders — "yesterday" is meaningless next session (postmortem hygiene). +# Deliberately does NOT include a bare "now" or "recently": both appear constantly in +# ordinary prose ("we now use tabs"), and gc dispatched a rewrite agent for every one — +# a high-frequency false positive (CONF53). Only unambiguous time-anchors are flagged. REL_DATE = re.compile( - r"\b(?:yesterday|today|tomorrow|tonight|now|" + r"\b(?:yesterday|today|tomorrow|tonight|" r"last\s+(?:night|week|month|year|time)|" r"next\s+(?:week|month|year|time)|" r"this\s+(?:morning|afternoon|evening|week|month|year)|" r"a\s+(?:few\s+)?(?:day|week|month|year)s?\s+ago|" r"\d+\s+(?:day|week|month|year)s?\s+ago|" - r"just\s+now|recently|earlier\s+today)\b", + r"just\s+now|earlier\s+today)\b", re.IGNORECASE, ) @@ -225,16 +227,27 @@ def open_db(base=None, readonly=False, recreate_on_corrupt=False): os.makedirs(os.path.dirname(path), exist_ok=True) if readonly and os.path.exists(path): conn = sqlite3.connect("file:%s?mode=ro" % path, uri=True, timeout=5) - if os.environ.get("FABLE_MEM_FORCE_DEGRADED") == "1": + forced_degraded = os.environ.get("FABLE_MEM_FORCE_DEGRADED") == "1" + # A schema-LESS but otherwise valid sqlite file (0-byte from touch/disk-full, + # or a sync tool clobbering it) has no `memories` table. Search/show query it + # directly, so probe it here and treat its absence as a corrupt/disposable + # index rather than letting the caller traceback later (CONF48). Do this even + # under FABLE_MEM_FORCE_DEGRADED, whose path also reads `memories`. + try: + conn.execute("SELECT 1 FROM memories LIMIT 1") + except sqlite3.OperationalError: # no such table: memories + conn.close() + raise CorruptIndex(path) + except sqlite3.DatabaseError: # file is not a database => corrupt + conn.close() + raise CorruptIndex(path) + if forced_degraded: return conn, "degraded-like" mode = "fts5" try: conn.execute("SELECT 1 FROM mem_fts LIMIT 1") - except sqlite3.OperationalError: # subclass of DatabaseError — list first + except sqlite3.OperationalError: # fts table absent => degraded search mode = "degraded-like" - except sqlite3.DatabaseError: # file is not a database => corrupt - conn.close() - raise CorruptIndex(path) return conn, mode conn = sqlite3.connect(path, timeout=10) try: @@ -377,7 +390,10 @@ def keywords(text): def _fts_match(query): - toks = WORD.findall(query) + # Use the SAME token filter as the degraded path (keywords(): len>2, stopwords + # dropped) so fts5 and LIKE modes agree on which tokens count — otherwise the same + # query could hit in one mode and return nothing in the other (CONF57). + toks = keywords(query) if not toks: return None return " OR ".join('"%s"' % t for t in toks) @@ -591,6 +607,20 @@ def privacy_scan(base=None): return 0, ["privacy: clean — no work markers in the global corpus"] +def index_privacy_advisory(base=None): + """A one-line warning (or []) that the sqlite index embeds project-memory bodies. + The index sits in the shareable memory dir and stores every project memory's BODY + verbatim — exactly the work-marker content the privacy boundary keeps out of that + dir — but the pattern scan can't read binary sqlite, so this warns explicitly rather + than let `doctor --privacy` imply the whole dir is safe to share (CONF49).""" + db = db_path(base) + if not os.path.exists(db): + return [] + return ["privacy: NOTE %s embeds project-memory bodies verbatim; it is a local cache — " + "do NOT share/back-up this dir publicly, or delete the index (mem.py rebuilds " + "it) before you do" % db] + + def cmd_doctor(args): # Must create the DB and exit 0 on a fresh, empty corpus. mdir = memory_dir() @@ -610,7 +640,7 @@ def cmd_doctor(args): % ("present" if os.path.exists(privacy_path()) else "absent")) if args.privacy: code, lines = privacy_scan() # scans corpus FILES, not the db — still valid - for ln in lines: + for ln in lines + index_privacy_advisory(): print(ln) return code or 1 return 1 @@ -623,7 +653,7 @@ def cmd_doctor(args): print("privacy_toml=%s" % ("present" if os.path.exists(ppath) else "absent")) if args.privacy: code, lines = privacy_scan() - for ln in lines: + for ln in lines + index_privacy_advisory(): print(ln) return code return 0 @@ -669,10 +699,17 @@ def gc_scan(base=None): near_dupes, stale, relative_dates, same_topic = [], [], [], [] # 1) near-duplicate name/description (normalized equality). + # A memory with no frontmatter `name:` falls back to its filename basename + # (parse_file), so every native per-project MEMORY.md shares the name "MEMORY". + # Matching on a filename-fallback name produced N-choose-2 bogus "identical name" + # pairs across unrelated projects (CONF50) — so only compare DECLARED names. + def declared_name(m): + n = _norm(m["name"]) + return n if n and n != _norm(slug_of(m["path"])) else "" for i in range(len(mems)): for j in range(i + 1, len(mems)): a, b = mems[i], mems[j] - if _norm(a["name"]) and _norm(a["name"]) == _norm(b["name"]): + if declared_name(a) and declared_name(a) == declared_name(b): near_dupes.append({"a": a["path"], "b": b["path"], "reason": "identical name"}) elif _norm(a["description"]) and _norm(a["description"]) == _norm(b["description"]): near_dupes.append({"a": a["path"], "b": b["path"], diff --git a/claude/cli/privacy.toml.example b/claude/cli/privacy.toml.example deleted file mode 100644 index 54cd3c2..0000000 --- a/claude/cli/privacy.toml.example +++ /dev/null @@ -1,23 +0,0 @@ -# fable-mem privacy patterns (installed to ~/.claude/memory/privacy.toml if absent) -# -# WHY: the promotion boundary is project -> global memory. A "work marker" is any -# token that must never cross into the machine-wide global corpus (`~/.claude/memory/`) -# — internal ticket ids, private hostnames, client/codenames. Two consumers read this: -# * pretool-mem-privacy-guard.py — BLOCKS a write into $BASE/memory/ whose pending -# content matches any pattern (deterministic gate). -# * `mem.py doctor --privacy` — detective sweep over the whole global corpus. -# -# PATTERN SYNTAX: each entry is a work-marker glob. `*` matches a run of non-space -# characters; everything else is a literal substring, matched anywhere in the file. -# "ACME-*" -> matches ACME-1234, ACME-TICKET, ... -# "internal.example.com" -> matches that hostname verbatim -# -# This list is CONSERVATIVE by design (necessary, not sufficient): it ships EMPTY so a -# fresh install never false-positives. Uncomment/extend with YOUR real markers. - -patterns = [ - # "ACME-*", - # "internal.example.com", - # "PROJECT-CODENAME", - # "*.corp.internal", -] diff --git a/claude/hooks/posttool-loop-alarm.py b/claude/hooks/posttool-loop-alarm.py index 645c64c..e203423 100644 --- a/claude/hooks/posttool-loop-alarm.py +++ b/claude/hooks/posttool-loop-alarm.py @@ -1,23 +1,30 @@ #!/usr/bin/env python3 -"""PostToolUse loop-alarm hook (fable-protocol). +"""PostToolUse / PostToolUseFailure loop-alarm hook (fable-protocol). Deterministic backstop for the grinding failure mode: the doctrine's "two failed fixes -> oracle" rule is advisory, and the benchmark showed advisory rules get skipped under momentum. This hook counts, per session, how many times the SAME -Bash command has failed since the last file modification. On the 3rd identical -failure (configurable via FABLE_LOOP_THRESHOLD; use 2 on smaller driver +Bash command has failed since the last successful file modification. On the 3rd +identical failure (configurable via FABLE_LOOP_THRESHOLD; use 2 on smaller driver models) it injects a one-time nudge (exit 2 -> stderr shown to the model; PostToolUse cannot block, the command already ran). -Key design point: any file modification (Edit/Write/NotebookEdit, or a -file-writing Bash command) clears all counts — re-running a check after a -change is legitimate iteration. Only "same command, still failing, nothing -changed in between" accumulates. - -Failure detection is conservative: a run only counts as failed when the -tool_response carries an explicit non-zero exit code (or an is_error flag). -If the payload carries no exit information the hook is inert — fail open, -never nag on green or unknown runs. +EVENT MODEL (verified against Claude Code 2.1.207 — DO NOT REGRESS) + A failing Bash command fires PostToolUseFailure, NOT PostToolUse, and its + payload carries NO exit-code/is_error field — failure is signalled by the + event itself (plus an `error` string). A succeeding command fires PostToolUse + with a `tool_response` and no exit code. The two events are mutually exclusive. + So this hook MUST be wired to BOTH events: PostToolUseFailure is the only place + it can see a failure, PostToolUse is where it observes the successes that reset + the grind counter. The pre-2.1 shape (exit codes inside a PostToolUse + tool_response) is still honoured via failed() so the hook works on both. + +Key design point: a SUCCESSFUL file modification (Edit/Write/NotebookEdit, or a +succeeding file-writing Bash command) clears all counts — re-running a check after +a change is legitimate iteration. A FAILING write-command does NOT reset (a write +that keeps failing is itself a grind — the CONF0 bug this replaces let such a +command clear its own count on every run). Only "same command, still failing, +nothing successfully changed in between" accumulates. """ import json import os @@ -44,9 +51,10 @@ def threshold(): pass return THRESHOLD_DEFAULT -MODIFYING_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} +MODIFYING_TOOLS = {"Edit", "Write", "NotebookEdit"} # Same conservative "this Bash command plausibly writes files" heuristic as the -# claim-audit gate (kept in sync by tests/test_loop_alarm.py). +# claim-audit gate. Kept byte-identical to stop-claim-audit.BASH_WRITE and enforced +# by tests/test_loop_alarm.py::test_bash_write_in_sync_with_claim_audit. BASH_WRITE = re.compile( r"(?>?\s*(?!&|/dev/(?:null|stdout|stderr)\b)\S" r"|(?:^|[|&;]\s*)(?:sed\s+(?:-\S+\s+)*-i|tee\s|patch\s|truncate\s" @@ -55,7 +63,7 @@ def threshold(): NUDGE = ( "LOOP ALARM (automated, fires once per command): this exact command has now failed " - "{n} times with no file modification in between. Running it again will not produce " + "{n} times with no successful change in between. Running it again will not produce " "new information. Stop grinding: (1) write the dead hypotheses down, one line each; " "(2) run the cheapest DIFFERENT experiment that discriminates between the survivors — " "or hand ALL evidence to the `oracle` agent now. A third identical attempt is the " @@ -64,7 +72,9 @@ def threshold(): def state_dir(): - d = os.environ.get("FABLE_STATE_DIR") or os.path.expanduser("~/.claude/tmp/fable-protocol") + d = os.environ.get("FABLE_STATE_DIR") or os.path.join( + os.environ.get("CLAUDE_DIR") or os.path.expanduser("~/.claude"), + "tmp", "fable-protocol") os.makedirs(d, exist_ok=True) return d @@ -103,7 +113,9 @@ def save_state(path, state): def failed(tool_response): - """True only on explicit failure evidence; unknown payloads never count.""" + """Legacy-CLI failure evidence: an explicit non-zero exit code / is_error flag + inside a PostToolUse tool_response. Claude Code 2.1.x omits these (it routes + failures to PostToolUseFailure instead), so this only matters on older builds.""" if not isinstance(tool_response, dict): return False for key in ("exit_code", "exitCode", "returnCode", "code"): @@ -124,25 +136,33 @@ def main(): path = os.path.join(d, f"loop-alarm-{session}.json") state = load_state(path) + event = data.get("hook_event_name", "") tool = data.get("tool_name", "") tool_input = data.get("tool_input") or {} cmd = tool_input.get("command", "") if isinstance(tool_input, dict) else "" cmd = re.sub(r"\s+", " ", cmd).strip() if isinstance(cmd, str) else "" - if tool in MODIFYING_TOOLS or (tool == "Bash" and cmd and BASH_WRITE.search(cmd)): - # Something changed — retrying checks is legitimate again. - state["counts"] = {} - save_state(path, state) + # Failure signal: the dedicated PostToolUseFailure event, or (legacy CLIs) an + # explicit non-zero exit inside a PostToolUse tool_response. + is_failure = event == "PostToolUseFailure" or failed(data.get("tool_response")) + + if not is_failure: + # A SUCCESSFUL modification (or succeeding write-command) means iteration + # moved forward — retrying checks is legitimate again, so clear everything. + if tool in MODIFYING_TOOLS or (tool == "Bash" and cmd and BASH_WRITE.search(cmd)): + state["counts"] = {} + save_state(path, state) + return 0 + # A non-write Bash command that succeeded clears only its own count. + if tool == "Bash" and cmd: + state["counts"].pop(cmd, None) + save_state(path, state) return 0 + # From here: this is a failure. Only Bash commands are tracked for grinding. if tool != "Bash" or not cmd: return 0 - if not failed(data.get("tool_response")): - state["counts"].pop(cmd, None) - save_state(path, state) - return 0 - n = state["counts"].get(cmd, 0) + 1 state["counts"][cmd] = n if n >= threshold() and cmd not in state["nudged"]: diff --git a/claude/hooks/posttool-test-weakening-alarm.py b/claude/hooks/posttool-test-weakening-alarm.py index 0a8672f..98bbcc1 100644 --- a/claude/hooks/posttool-test-weakening-alarm.py +++ b/claude/hooks/posttool-test-weakening-alarm.py @@ -4,10 +4,9 @@ The reward-hacking variant of the false-green failure mode: greening a failing suite by skipping/disabling the test instead of fixing the code. The doctrine forbids it and the Stop-hook audit asks about it after the fact — but both are -downstream of the edit. This hook watches the edit itself: when an Edit/Write/ -MultiEdit adds a skip/disable marker to a test file, it injects a one-time -nudge (exit 2 -> stderr shown to the model; PostToolUse cannot block, the edit -already landed). +downstream of the edit. This hook watches the edit itself: when an Edit or Write +adds a skip/disable marker to a test file, it injects a one-time nudge (exit 2 -> +stderr shown to the model; PostToolUse cannot block, the edit already landed). Only ADDED markers count: an edit that merely moves existing skips around, or touches a file that already had them, stays silent (occurrences in the new @@ -33,6 +32,13 @@ re.IGNORECASE, ) + +def is_test_path(p): + """TEST_PATH match with backslash separators normalized — native-Windows + file_path values reach this hook with backslashes, and without normalization + the tests/ and test_*.py heuristics never fire on Windows (CONF1).""" + return bool(TEST_PATH.search(p.replace("\\", "/"))) if isinstance(p, str) else False + # Skip/disable markers across the mainstream ecosystems. Conservative: each # pattern is something a test author writes to STOP a test from running, not # something that appears in ordinary test bodies. @@ -58,7 +64,9 @@ def state_dir(): - d = os.environ.get("FABLE_STATE_DIR") or os.path.expanduser("~/.claude/tmp/fable-protocol") + d = os.environ.get("FABLE_STATE_DIR") or os.path.join( + os.environ.get("CLAUDE_DIR") or os.path.expanduser("~/.claude"), + "tmp", "fable-protocol") os.makedirs(d, exist_ok=True) return d @@ -82,15 +90,6 @@ def added_markers(tool, tool_input): """True iff this tool call introduces skip markers that were not there before.""" if tool == "Edit": return marker_count(tool_input.get("new_string")) > marker_count(tool_input.get("old_string")) - if tool == "MultiEdit": - edits = tool_input.get("edits") - if not isinstance(edits, list): - return False - return any( - isinstance(e, dict) - and marker_count(e.get("new_string")) > marker_count(e.get("old_string")) - for e in edits - ) if tool == "Write": # No old content in the payload; compare against the file on disk is # impossible post-write. A brand-new test file written WITH a skip in it @@ -102,13 +101,13 @@ def added_markers(tool, tool_input): def main(): data = json.load(sys.stdin) tool = data.get("tool_name", "") - if tool not in ("Edit", "Write", "MultiEdit"): + if tool not in ("Edit", "Write"): return 0 tool_input = data.get("tool_input") if not isinstance(tool_input, dict): return 0 path = tool_input.get("file_path", "") - if not isinstance(path, str) or not TEST_PATH.search(path): + if not is_test_path(path): return 0 if not added_markers(tool, tool_input): return 0 diff --git a/claude/hooks/precompact-save-task.py b/claude/hooks/precompact-save-task.py index a341e51..8b65fa1 100644 --- a/claude/hooks/precompact-save-task.py +++ b/claude/hooks/precompact-save-task.py @@ -20,7 +20,9 @@ def state_dir(): - d = os.environ.get("FABLE_STATE_DIR") or os.path.expanduser("~/.claude/tmp/fable-protocol") + d = os.environ.get("FABLE_STATE_DIR") or os.path.join( + os.environ.get("CLAUDE_DIR") or os.path.expanduser("~/.claude"), + "tmp", "fable-protocol") os.makedirs(d, exist_ok=True) return d diff --git a/claude/hooks/pretool-destructive-guard.py b/claude/hooks/pretool-destructive-guard.py index 26b804d..e2e0469 100644 --- a/claude/hooks/pretool-destructive-guard.py +++ b/claude/hooks/pretool-destructive-guard.py @@ -27,14 +27,19 @@ import sys OVERRIDE = "FABLE_DESTRUCTIVE_OK=1" +# The override only counts as an actual env-assignment prefix on a command segment — +# NOT merely mentioned anywhere (a commit message or echo that contains the string +# must not disable the guard for the whole line). Matched against the quote-stripped +# view so quoted text can never supply it. +OVERRIDE_PREFIX = re.compile(r"(?:^|[;&|]\s*)FABLE_DESTRUCTIVE_OK=1(?:\s|$)") # (pattern, why) — matched per shell segment context via a whole-command regex; # [^|;&]* keeps a match from spanning into the next piped/chained command. TREE_DESTROYERS = [ (re.compile(r"\bgit\b[^|;&]*\breset\b[^|;&]*--hard"), "git reset --hard discards ALL uncommitted changes"), - (re.compile(r"\bgit\b[^|;&]*\bcheckout\b[^|;&]*(?:\s--(?:\s|$)|\s-f\b|\s\.(?:\s|$|;))"), - "git checkout with --/-f/. overwrites uncommitted local modifications"), + (re.compile(r"\bgit\b[^|;&]*\bcheckout\b[^|;&]*(?:\s--(?:\s|$)|\s-f\b|\s\.\.?/?(?:\s|$|;))"), + "git checkout with --/-f/./.. overwrites uncommitted local modifications"), (re.compile(r"\bgit\b[^|;&]*\bclean\b[^|;&]*\s-[a-zA-Z]*f"), "git clean -f permanently deletes untracked files"), (re.compile(r"\bgit\b[^|;&]*\bswitch\b[^|;&]*(?:\s-f\b|\s--force\b|\s--discard-changes\b)"), @@ -88,20 +93,24 @@ def main(): if not isinstance(cmd, str) or not cmd.strip(): return 0 flat = re.sub(r"\s+", " ", cmd) - if OVERRIDE in flat: - return 0 # Git patterns match on a quote-stripped view so a commit message or echo # that merely MENTIONS "reset --hard" never trips the guard. The rm pattern # matches the raw command — its targets are often quoted. unquoted = re.sub(r"'[^']*'|\"[^\"]*\"", " ", flat) + if OVERRIDE_PREFIX.search(unquoted): + return 0 for pat, why in ALWAYS_DANGEROUS: haystack = flat if why.startswith("recursive rm") else unquoted if pat.search(haystack): return block(why) - if FORCE_PUSH.search(unquoted) and not FORCE_WITH_LEASE.search(unquoted): - return block("bare force-push can destroy remote history; use --force-with-lease, " - "and only with user approval") + # Force-push must be judged PER SEGMENT: `--force-with-lease` anywhere else on + # the line (a chained safe push, an echo) must not excuse a bare --force in a + # different segment. Split on shell separators and check each segment alone. + for segment in re.split(r"[|;&]+", unquoted): + if FORCE_PUSH.search(segment) and not FORCE_WITH_LEASE.search(segment): + return block("bare force-push can destroy remote history; use --force-with-lease, " + "and only with user approval") tree_reason = None for pat, why in TREE_DESTROYERS: diff --git a/claude/hooks/sessionend-mem-journal.py b/claude/hooks/sessionend-mem-journal.py index ff62749..1b95e89 100644 --- a/claude/hooks/sessionend-mem-journal.py +++ b/claude/hooks/sessionend-mem-journal.py @@ -142,14 +142,15 @@ def write_journal(base, data): def reindex(base): """Incremental `mem.py index` side effect — best-effort, hard-timeout-bounded. - Env is propagated so CLAUDE_DIR reaches the subprocess and it indexes THIS base.""" + Pins CLAUDE_DIR=base so the subprocess indexes the SAME corpus this hook resolved, + even if the caller passed a base that differs from the ambient CLAUDE_DIR.""" mem_py = os.path.join(base, "cli", "mem.py") if not os.path.exists(mem_py): return try: subprocess.run( [sys.executable, mem_py, "index"], - env=os.environ.copy(), + env=dict(os.environ, CLAUDE_DIR=base), capture_output=True, text=True, timeout=REINDEX_TIMEOUT, ) except Exception: diff --git a/claude/hooks/sessionstart-compact-recovery.py b/claude/hooks/sessionstart-compact-recovery.py index e8ef662..6728062 100644 --- a/claude/hooks/sessionstart-compact-recovery.py +++ b/claude/hooks/sessionstart-compact-recovery.py @@ -28,13 +28,21 @@ def state_dir(): - return os.environ.get("FABLE_STATE_DIR") or os.path.expanduser("~/.claude/tmp/fable-protocol") + return os.environ.get("FABLE_STATE_DIR") or os.path.join( + os.environ.get("CLAUDE_DIR") or os.path.expanduser("~/.claude"), + "tmp", "fable-protocol") + + +# Total git budget must fit inside the hook's 10s timeout with headroom: two calls +# at 3s each = 6s worst case, so a hung repo can never starve the flush below (CONF7). +GIT_TIMEOUT = 3 def run(cmd, cwd): """Returns (ok, stdout) — ok distinguishes 'clean output' from 'not a repo'.""" try: - p = subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True, timeout=8) + p = subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True, + timeout=GIT_TIMEOUT) return p.returncode == 0, p.stdout except Exception: return False, "" @@ -58,6 +66,11 @@ def main(): print("\n--- original request (verbatim, saved pre-compaction) ---") print(task) + # Flush the two things a git hang must never lose (protocol + original request) + # BEFORE spending any of the budget on subprocesses. Under a hook, stdout is + # block-buffered, so a timeout-kill mid-git would otherwise discard everything. + sys.stdout.flush() + cwd = data.get("cwd") ok, status = run(["git", "status", "--short"], cwd) if ok: diff --git a/claude/hooks/stop-claim-audit.py b/claude/hooks/stop-claim-audit.py index a93a3e2..cbe96fe 100755 --- a/claude/hooks/stop-claim-audit.py +++ b/claude/hooks/stop-claim-audit.py @@ -30,7 +30,7 @@ r"(?:done|completed?|finished|verified|fixed|resolved|implemented)\b", re.IGNORECASE, ) -MODIFYING_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} +MODIFYING_TOOLS = {"Edit", "Write", "NotebookEdit"} # Bash commands that plausibly write files: redirections (except to /dev/*), # in-place editors, file movers. Conservative — read-only sessions stay untaxed. BASH_WRITE = re.compile( @@ -66,6 +66,14 @@ ) +def is_test_path(p): + """TEST_PATH match with backslash paths normalized to forward slashes, so the + directory/pytest heuristics fire on native-Windows file_path values (C:\\r\\tests\\ + test_x.py) that the Edit/Write tools emit — otherwise the gate's test-edit + detection is silently inert on Windows (CONF1).""" + return bool(TEST_PATH.search(p.replace("\\", "/"))) if isinstance(p, str) else False + + def makes_claim(text): return bool(CLAIM.search(NEGATED.sub("", text))) @@ -79,7 +87,7 @@ def bash_touches_tests(cmd): > out.log`) does not count — only tokens naming something inside one. """ for token in re.split(r"[\s;|&<>()]+", cmd): - token = token.strip("'\"`") + token = token.strip("'\"`").replace("\\", "/") if not token or re.fullmatch(r"\.?/?(tests?|__tests__|spec)/?", token, re.IGNORECASE): continue if TEST_PATH.search(token): @@ -117,7 +125,7 @@ def main(): modified = True inp = block.get("input") fp = inp.get("file_path", "") if isinstance(inp, dict) else "" - if isinstance(fp, str) and TEST_PATH.search(fp): + if is_test_path(fp): modified_tests = True elif name == "Bash": inp = block.get("input") diff --git a/claude/hooks/userpromptsubmit-mem-recall.py b/claude/hooks/userpromptsubmit-mem-recall.py index d880507..b07c6e0 100644 --- a/claude/hooks/userpromptsubmit-mem-recall.py +++ b/claude/hooks/userpromptsubmit-mem-recall.py @@ -79,7 +79,9 @@ def db_path(base): def state_dir(): - d = os.environ.get("FABLE_STATE_DIR") or os.path.expanduser("~/.claude/tmp/fable-protocol") + d = os.environ.get("FABLE_STATE_DIR") or os.path.join( + os.environ.get("CLAUDE_DIR") or os.path.expanduser("~/.claude"), + "tmp", "fable-protocol") os.makedirs(d, exist_ok=True) return d diff --git a/claude/settings/settings-snippet-small.json b/claude/settings/settings-snippet-small.json index 4e4fa1e..eafb611 100644 --- a/claude/settings/settings-snippet-small.json +++ b/claude/settings/settings-snippet-small.json @@ -43,7 +43,7 @@ ] }, { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit", "hooks": [ { "type": "command", @@ -80,7 +80,7 @@ ], "PostToolUse": [ { - "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "matcher": "Bash|Edit|Write|NotebookEdit", "hooks": [ { "type": "command", @@ -90,7 +90,7 @@ ] }, { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write", "hooks": [ { "type": "command", @@ -100,6 +100,18 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/claude/settings/settings-snippet-windows-small.json b/claude/settings/settings-snippet-windows-small.json index 473823b..419f213 100644 --- a/claude/settings/settings-snippet-windows-small.json +++ b/claude/settings/settings-snippet-windows-small.json @@ -43,7 +43,7 @@ ] }, { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit", "hooks": [ { "type": "command", @@ -80,7 +80,7 @@ ], "PostToolUse": [ { - "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "matcher": "Bash|Edit|Write|NotebookEdit", "hooks": [ { "type": "command", @@ -90,7 +90,7 @@ ] }, { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write", "hooks": [ { "type": "command", @@ -100,6 +100,18 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/claude/settings/settings-snippet-windows.json b/claude/settings/settings-snippet-windows.json index 2ef456a..0b5278a 100644 --- a/claude/settings/settings-snippet-windows.json +++ b/claude/settings/settings-snippet-windows.json @@ -42,7 +42,7 @@ ] }, { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit", "hooks": [ { "type": "command", @@ -79,7 +79,7 @@ ], "PostToolUse": [ { - "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "matcher": "Bash|Edit|Write|NotebookEdit", "hooks": [ { "type": "command", @@ -89,7 +89,7 @@ ] }, { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write", "hooks": [ { "type": "command", @@ -99,6 +99,18 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/claude/settings/settings-snippet.json b/claude/settings/settings-snippet.json index dfd5c49..014a48e 100644 --- a/claude/settings/settings-snippet.json +++ b/claude/settings/settings-snippet.json @@ -42,7 +42,7 @@ ] }, { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit", "hooks": [ { "type": "command", @@ -79,7 +79,7 @@ ], "PostToolUse": [ { - "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "matcher": "Bash|Edit|Write|NotebookEdit", "hooks": [ { "type": "command", @@ -89,7 +89,7 @@ ] }, { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write", "hooks": [ { "type": "command", @@ -99,6 +99,18 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/claude/skills/fable/SKILL.md b/claude/skills/fable/SKILL.md index 1f2d5a6..f7ebc0a 100644 --- a/claude/skills/fable/SKILL.md +++ b/claude/skills/fable/SKILL.md @@ -40,11 +40,14 @@ stages because you feel confident — feeling confident is not evidence. - Re-read the original request one last time and check the task list: every deliverable shipped? - Non-obvious lessons (surprising root cause, dead-end approach, environment quirk) → postmortem skill → auto-memory. -## If the Workflow tool is unavailable -The /deep-plan and /paranoid-review stages assume saved workflows. Your invoking this -skill authorizes those runs; but if the Workflow tool is genuinely unavailable in the -session, do the same stage with Agent-tool subagents (3 refuters / plan critic / +## Workflow stages honor the orchestration gate +The /deep-plan and /paranoid-review stages use the Workflow tool, which is the user's +money — so they run ONLY when the session is opted in (the user typed "ultracode", +invoked /fable or another slash command, or asked for orchestration in their own words). +When this skill fired on its own auto-trigger, or the Workflow tool is otherwise +unavailable, do the SAME stage with Agent-tool subagents (3 refuters / plan critic / per-dimension reviewers spawned directly) — degrade the machinery, never the rigor. +Invoking a skill does not by itself authorize Workflow runs. ## Cost honesty This protocol multiplies agent spend. That is the point — but say what it cost when diff --git a/claude/skills/memory-search/SKILL.md b/claude/skills/memory-search/SKILL.md index ad25e38..a669979 100644 --- a/claude/skills/memory-search/SKILL.md +++ b/claude/skills/memory-search/SKILL.md @@ -21,18 +21,17 @@ to re-derive from scratch may already be banked from another repo. Search first. - Routine edits where no cross-project lesson could change the answer. ## Commands -Run against the installed CLI (BASE-resolved — honors `CLAUDE_DIR`). On Windows, invoke `python` wherever a command below says `python3`: -- `python3 ~/.claude/cli/mem.py search ""` — top hits (title + one-line description + path). Add `--json` for structured output, `--scope global` (or `project`) to isolate a scope. -- `python3 ~/.claude/cli/mem.py show ` — read one memory's full body. -- `python3 ~/.claude/cli/mem.py stats` — per-scope counts (sanity-check the corpus is indexed). -- `python3 ~/.claude/cli/mem.py doctor` — FTS mode + corpus health if search behaves oddly. +The CLI lives at `$CLAUDE_DIR/cli/mem.py` (default `~/.claude/cli/mem.py`) and resolves +the corpus from `CLAUDE_DIR` — so under a custom `CLAUDE_DIR`, use that path, not the +`~/.claude` literal shown here. On Windows, invoke `python` wherever a command says `python3`: +- `python3 "${CLAUDE_DIR:-~/.claude}/cli/mem.py" search ""` — top hits (title + one-line description + path). Add `--json` for structured output, `--scope global` (or `project`) to isolate a scope. +- `... mem.py show ` — read one memory's full body. +- `... mem.py stats` — per-scope counts (sanity-check the corpus is indexed). +- `... mem.py doctor` — FTS mode + corpus health if search behaves oddly. The UserPromptSubmit recall hook already surfaces the top few cross-project hits automatically; use these commands when you need to search deliberately, widen beyond the auto-surfaced 3, or read a full body. ## Banking and promotion -To bank a lesson, use the **postmortem** skill — it writes the memory and decides scope. -A memory stays project-local by default; promotion project→global happens ONLY on an explicit -decision with a one-line why-global. The deterministic gate is `pretool-mem-privacy-guard.py`, -which blocks a Write/Edit/MultiEdit into the corpus carrying a work-marker regardless of intent -— so the common promotion path is prevention-by-hook. It does not see Bash/interpreter writes -(`cat >>`, `python3 -c`); run `mem doctor --privacy` as the backstop before promoting. +To bank a lesson or promote one project→global, use the **postmortem** skill — it is the +single source of truth for the write mechanics and the privacy-guard boundary. Search here +first (step above) to confirm the lesson isn't already banked before you promote. diff --git a/claude/skills/orchestrate/SKILL.md b/claude/skills/orchestrate/SKILL.md index 5c457ee..1f8e62e 100644 --- a/claude/skills/orchestrate/SKILL.md +++ b/claude/skills/orchestrate/SKILL.md @@ -26,9 +26,9 @@ for that, not you. You may call it only when one of these holds: - The user asked for orchestration in their own words ("use a workflow", "fan out agents") — a task that would merely *benefit* from one does not count. - A skill or slash command the user invoked tells you to call Workflow (the kit's - /paranoid-review, /verify-claim, /deep-plan, /bug-hunt, /big-task, - /design-variants all qualify — the USER invoking the command is the opt-in for - that run; a skill you auto-triggered yourself is not). + /paranoid-review, /verify-claim, /deep-plan, /bug-hunt, /big-task, /design-variants, + /memory-gc, /memory-review all qualify — the USER invoking the command is the opt-in + for that run; a skill you auto-triggered yourself is not). Otherwise: use Agent-tool subagents, or briefly describe what a workflow would do and roughly cost, and tell the user they can say "ultracode" or "use a workflow" to get it. @@ -81,5 +81,8 @@ as `budget` — `budget.total` (null if no target), `budget.spent()`, `budget.re /paranoid-review (diff review), /verify-claim (claim refutation), /deep-plan (judge-panel planning), /bug-hunt (loop-until-dry sweep), /big-task (checkpointed decompose → implement → strong-verify → commit), /design-variants (judge-panel web design: -competing HTML previews, German-market judge when the brief says German). Check -these before authoring a new script — the pattern you need may already be a command. +competing HTML previews, German-market judge when the brief says German), +/memory-gc (corpus-hygiene sweep: dedup/stale/contradiction proposals, absolutize +relative dates), /memory-review (mine the session journal for high-activity sessions +that banked nothing, propose the lessons worth capturing). Check these before +authoring a new script — the pattern you need may already be a command. diff --git a/claude/workflows/big-task.js b/claude/workflows/big-task.js index 3d4ab0f..2add5fb 100644 --- a/claude/workflows/big-task.js +++ b/claude/workflows/big-task.js @@ -15,15 +15,26 @@ if (!raw) return { error: 'Usage: /big-task [--verify-model=o let verifyModel = null let maxSteps = 10 +let badFlag = null const task = raw .replace(/--verify-model=(\S+)/, (_, m) => { verifyModel = m; return '' }) - .replace(/--max-steps=(\d+)/, (_, n) => { maxSteps = Math.max(1, Math.min(20, +n)); return '' }) + .replace(/--max-steps=(\S+)/, (_, n) => { + const v = /^\d+$/.test(n) ? +n : NaN + if (!Number.isFinite(v) || v < 1) badFlag = `--max-steps needs a positive integer, got "${n}"` + else maxSteps = Math.min(20, v) + return '' + }) .trim() +if (badFlag) return { error: `${badFlag}. Usage: /big-task [--verify-model=opus] [--max-steps=N]` } if (!task) return { error: 'No task text left after flags. Usage: /big-task [--verify-model=opus] [--max-steps=N]' } -// Draft cheap, verify strong: verifiers always run at xhigh effort, and are pinned -// to a stronger model when the caller has one to give (--verify-model=opus). +// Draft cheap, verify strong: the VERIFICATION roles (plan-critic, per-step verifier, +// completeness critic) run at xhigh and are pinned to the stronger model when the +// caller gives one. The PLANNERS (decompose + repair) are drafters: xhigh effort, but +// never the verify-model pin — otherwise --verify-model would silently upgrade planning +// too, contradicting the flag's meaning and the draft-cheap/verify-strong doctrine. const strong = { effort: 'xhigh', ...(verifyModel ? { model: verifyModel } : {}) } +const planEffort = { effort: 'xhigh' } const PLAN = { type: 'object', @@ -74,7 +85,7 @@ Explore the actual codebase first; verify assumptions rather than guessing. Then - committable: the repo is in a coherent, green state after it (no step may leave the build broken for a later step to fix); - small: a step a careful junior engineer could execute without asking questions. Also identify the project's canonical full check (test suite, build, verify script). If the task is genuinely one or two steps, return exactly those — do not pad.` -let plan = await agent(planPrompt, { label: 'decompose', phase: 'Decompose', schema: PLAN, ...strong }) +let plan = await agent(planPrompt, { label: 'decompose', phase: 'Decompose', schema: PLAN, ...planEffort }) if (!plan || !plan.steps?.length) return { error: 'Decomposition failed — no plan produced' } const planText = p => p.steps.map((s, i) => `${i + 1}. ${s.goal}\n ${s.detail}\n check: ${s.check}`).join('\n') @@ -97,7 +108,7 @@ A critic found these problems in a previous decomposition attempt — your plan ${critique.problems.map(p => '- ' + p).join('\n')} Previous plan for reference: ${planText(plan)}`, - { label: 'decompose:v2', phase: 'Decompose', schema: PLAN, ...strong } + { label: 'decompose:v2', phase: 'Decompose', schema: PLAN, ...planEffort } ) if (repaired?.steps?.length) plan = repaired else log('repair planner failed — proceeding with the original plan plus the critique as a known risk') @@ -106,7 +117,6 @@ log(`${plan.steps.length} step(s); canonical check: ${plan.canonicalCheck}`) // ---- Execute: implement -> verify -> (repair -> re-verify) -> commit, per step ---- const done = [] -const commits = [] let halted = null for (let i = 0; i < plan.steps.length; i++) { @@ -148,9 +158,15 @@ verdict=pass ONLY if both checks pass under your own execution AND the diff genu const repair = await agent(implPrompt(problems), { label: `repair:${i + 1}`, phase: 'Execute' }) v = repair == null ? null : await verify() if (v?.verdict !== 'pass') { - // Two rejected attempts: stop grinding (doctrine). Leave the dirty tree for the caller. - halted = { step: i + 1, goal: step.goal, reason: 'step failed adversarial verification twice', problems: v?.problems ?? problems, evidence: v?.evidence ?? null } - log(`step ${n} failed twice — halting. Completed checkpoints remain committed; the failed attempt is uncommitted in the working tree.`) + // Stop grinding (doctrine). Leave the dirty tree for the caller. Distinguish + // "the repair implementer died" (second verification never ran) from a genuine + // second verification failure — the halt reason must not claim a check that + // never executed (CONF15). + const reason = repair == null + ? 'repair implementer died after one rejected verification' + : 'step failed adversarial verification twice' + halted = { step: i + 1, goal: step.goal, reason, problems: v?.problems ?? problems, evidence: v?.evidence ?? null } + log(`step ${n} halting (${reason}). Completed checkpoints remain committed; the failed attempt is uncommitted in the working tree.`) break } } @@ -164,7 +180,6 @@ Return the commit hash, or the exact error if the commit fails (nothing staged c { label: `commit:${i + 1}`, phase: 'Execute', effort: 'low' } ) done.push({ step: i + 1, goal: step.goal, evidence: v.evidence, commit: commit ?? 'COMMIT AGENT DIED — checkpoint may be uncommitted, check git log' }) - commits.push(commit) log(`step ${n} verified and committed`) } diff --git a/claude/workflows/bug-hunt.js b/claude/workflows/bug-hunt.js index b26465f..bb9396c 100644 --- a/claude/workflows/bug-hunt.js +++ b/claude/workflows/bug-hunt.js @@ -64,8 +64,9 @@ const unverified = [] const key = b => `${b.file}:${(b.title || '').toLowerCase().slice(0, 60)}` let dry = 0 +let stoppedForBudget = false for (let round = 0; round < MAX_ROUNDS && dry < 2; round++) { - if (budget.total && budget.remaining() < 40_000) { log('token budget nearly spent — stopping early'); break } + if (budget.total && budget.remaining() < 40_000) { log('token budget nearly spent — stopping early'); stoppedForBudget = true; break } phase('Hunt') const roundLenses = Array.from({ length: LENSES_PER_ROUND }, (_, i) => LENSES[(round * LENSES_PER_ROUND + i) % LENSES.length]) @@ -106,7 +107,10 @@ verdict=confirmed only if the code demonstrably has this defect; refuted if it i }) } -if (dry < 2) log(`round cap (${MAX_ROUNDS}) reached while the hunt was still surfacing new findings — coverage is NOT exhaustive`) +// Distinguish WHY the hunt stopped: only claim the round cap when it was actually +// reached, not when we broke out early on budget (CONF17). +if (stoppedForBudget) log('hunt stopped early on token budget before exhaustion — coverage is NOT exhaustive') +else if (dry < 2) log(`round cap (${MAX_ROUNDS}) reached while the hunt was still surfacing new findings — coverage is NOT exhaustive`) const SEVERITY_RANK = { critical: 0, major: 1, minor: 2 } const bySeverity = (a, b) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3) log(`done: ${confirmed.length} confirmed, ${refuted.length} refuted, ${unverified.length} unverified (${seen.size} total found)`) diff --git a/claude/workflows/design-variants.js b/claude/workflows/design-variants.js index deccb0f..f8b8f2e 100644 --- a/claude/workflows/design-variants.js +++ b/claude/workflows/design-variants.js @@ -14,11 +14,15 @@ const brief = (typeof args === 'string' && args.trim()) ? args.trim() : null if (!brief) return { error: 'Usage: /design-variants ' } // German-market mode: adds a compliance judge and puts the german-market checklist -// in every builder's brief. Cheap heuristic on the brief text — explicit market -// keywords OR German-language signals (umlauts/ß, „quotes", GmbH, common function -// words), since a brief WRITTEN in German is the strongest signal of all. When in -// doubt, say "German market" in the brief. -const german = /german|deutsch|germany|dach\b|\.de\b|impressum|dsgvo|[äöüß„]|\bGmbH\b|\bund\b|\beine[nrms]?\b/i.test(brief) +// in every builder's brief. An explicit market keyword turns it on outright. Absent +// that, a SINGLE umlaut is not enough — an English brief naming "Motörhead" or +// "Zürich" must not trip it (CONF26) — so a brief merely WRITTEN in German needs at +// least two independent language signals. When in doubt, say "German market". +const germanKeyword = /german|deutsch|germany|dach\b|\.de\b|impressum|dsgvo/i.test(brief) +const languageSignals = [/[äöüß]/, /„/, /\bGmbH\b/, /\bund\b/i, /\beine[nrms]?\b/i] + .filter(re => re.test(brief)).length +const german = germanKeyword || languageSignals >= 2 +if (german && !germanKeyword) log(`german-market mode enabled by language heuristic (${languageSignals} signals) — add an explicit market keyword to be sure`) const SKILL_HINT = `If the installed webdesign skill references exist — default location ~/.claude/skills/webdesign/references/, or under $CLAUDE_DIR/skills/webdesign/references/ when that env var is set — read design-views.md${german ? ' AND german-market.md' : ''} from there FIRST and follow them; if absent, proceed from the constraints in this prompt alone.` diff --git a/claude/workflows/memory-gc.js b/claude/workflows/memory-gc.js index c8f4196..eda87cf 100644 --- a/claude/workflows/memory-gc.js +++ b/claude/workflows/memory-gc.js @@ -149,7 +149,10 @@ This writes only the disposable index, never a memory file. Report the command's ) if (rebuilt == null) log('reindex agent died — run `python3 $CLAUDE_DIR/cli/mem.py index --rebuild` by hand') -log(`memory-gc done — ${contradictions.length + nearDup.length + stale.length} deletion/merge proposal(s), ${dateFix.rewritten.length} date fix(es) ${dryRun ? 'proposed' : 'applied'}`) +// In --dry-run the date fixes are PROPOSED (they land in dateFix.skipped, not +// .rewritten), so report the candidate count, not the applied count (CONF18). +const dateFixCount = dryRun ? relDates.length : dateFix.rewritten.length +log(`memory-gc done — ${contradictions.length + nearDup.length + stale.length} deletion/merge proposal(s), ${dateFixCount} date fix(es) ${dryRun ? 'proposed' : 'applied'}`) return { dryRun, scan: { nearDuplicates: nearDup.length, stale: stale.length, relativeDates: relDates.length, sameTopicPairs: topicPairs.length }, diff --git a/claude/workflows/memory-review.js b/claude/workflows/memory-review.js index 67d5784..e3bdc2c 100644 --- a/claude/workflows/memory-review.js +++ b/claude/workflows/memory-review.js @@ -13,10 +13,14 @@ const raw = (typeof args === 'string' ? args : '').trim() let top = 6 let minDirty = 8 let badFlag = null -raw +const residue = raw .replace(/--top=(\S+)/, (_, v) => { const n = Number(v); if (Number.isInteger(n) && n > 0) top = Math.min(20, n); else badFlag = `--top=${v}`; return '' }) .replace(/--min-dirty=(\S+)/, (_, v) => { const n = Number(v); if (Number.isInteger(n) && n >= 0) minDirty = n; else badFlag = `--min-dirty=${v}`; return '' }) + .trim() if (badFlag) return { error: `Bad flag ${badFlag}. Usage: /memory-review [--top=N] [--min-dirty=N]` } +// Reject anything left over (typo'd flag, --min_dirty=, `--top 5` with a space) rather +// than silently running with defaults — mirrors memory-gc.js (CONF19). +if (residue) return { error: `Unrecognized argument(s): "${residue}". Usage: /memory-review [--top=N] [--min-dirty=N]` } // Every agent resolves the memory base the same way the CLI and hooks do — never // hardcode ~/.claude, so a scratch CLAUDE_DIR is honored. diff --git a/claude/workflows/paranoid-review.js b/claude/workflows/paranoid-review.js index ba3bafa..00b7ddf 100644 --- a/claude/workflows/paranoid-review.js +++ b/claude/workflows/paranoid-review.js @@ -74,7 +74,13 @@ Report EVERY real issue you find regardless of severity — a separate verificat (r, [key]) => { // A dead finder must be visible, not read as "dimension found nothing clean". if (r == null) { log(`find:${key}: FINDER DIED — this dimension is UNREVIEWED`); return [] } - if (budget.total && budget.remaining() < 30_000) { log(`find:${key}: token budget nearly spent — findings reported UNVERIFIED`); return (r.findings ?? []).map(f => ({ ...f, dimension: key, verdict: null })) } + if (budget.total && budget.remaining() < 30_000) { + // Still dedup on the budget path, or the same defect surfaces twice — once + // confirmed by an earlier dimension, once unverified here (CONF20). + log(`find:${key}: token budget nearly spent — findings reported UNVERIFIED`) + return (r.findings ?? []).filter(f => !seen.has(dedupKey(f)) && seen.add(dedupKey(f))) + .map(f => ({ ...f, dimension: key, verdict: null })) + } const fresh = (r?.findings ?? []).filter(f => !seen.has(dedupKey(f)) && seen.add(dedupKey(f))) const dupes = (r?.findings?.length ?? 0) - fresh.length if (dupes) log(`find:${key}: ${dupes} duplicate finding(s) already claimed by another dimension`) diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index 85e37ad..63125f4 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -80,9 +80,12 @@ adversarial review of the kit itself. Sources inline. produced 15 findings; all should-fixes applied (trigger partitions between overlapping verification paths, three-way verdicts instead of silent drops, observable loop triggers, effort floor, deterministic compact hook). -- Every component live-verified on `claude-opus-4-8` (Claude Code 2.1.198, 2026-07-02): - doctrine quoted verbatim from a fresh session, all agents visible and spawnable - (incl. `effort: max` frontmatter), all workflow commands registered. +- Every **v1.0/v1.1** component live-verified on `claude-opus-4-8` (Claude Code 2.1.198, + 2026-07-02): doctrine quoted verbatim from a fresh session, all agents visible and + spawnable (incl. `effort: max` frontmatter), all workflow commands registered. + Components added later (v1.2+ hooks, doctor, small-tier, `/big-task`, fable-mem, the + Windows port) are unit- and CI-tested but have NOT all had a live-session pass — see + the README's Known limits for the current live-verification scope. - Honest residual: `CLAUDE_CODE_MAX_OUTPUT_TOKENS` effectiveness unverified (has a documented history of being ignored on some versions: [#24159](https://github.com/anthropics/claude-code/issues/24159)); kept because it is clamped per-model and therefore harmless. diff --git a/install.sh b/install.sh index 5af62a2..e2ca25d 100755 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ while [ $# -gt 0 ]; do case "$1" in --tier) TIER="${2:?--tier needs a value (small|opus)}"; shift 2 ;; --strong-model) STRONG_MODEL="${2:?--strong-model needs a model name, e.g. opus}"; shift 2 ;; - -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,"");print;next} {exit}' "$0"; exit 0 ;; *) echo "unknown flag: $1 (see --help)"; exit 1 ;; esac done @@ -27,7 +27,8 @@ STAMP="$(date +%Y%m%d-%H%M%S)" # left inside skills/ would itself be loaded by Claude Code as a duplicate skill. BAK="$DST/fable-protocol-backups/$STAMP" -# backup — move the existing target into the backup dir. +# backup — copy the existing target into the backup dir +# (the original stays in place; the copy is the safety net before it is overwritten). backup() { [ -e "$1" ] || return 0 mkdir -p "$BAK/$(dirname "$2")" @@ -46,7 +47,7 @@ identical() { [ -f "$2" ] && cmp -s "$1" "$2"; } skill_unchanged() { local f rel [ -f "$2/.fable-manifest" ] || return 1 - diff -q <(cd "$1" && find . -type f | sort) "$2/.fable-manifest" >/dev/null 2>&1 || return 1 + diff -q <(cd "$1" && find . -type f | LC_ALL=C sort) "$2/.fable-manifest" >/dev/null 2>&1 || return 1 while IFS= read -r -d '' f; do rel="${f#"$1"/}" cmp -s "$f" "$2/$rel" || return 1 @@ -99,7 +100,7 @@ for d in "$SRC"/skills/*/; do skill_unchanged "${d%/}" "$t" && { echo " skill: $name (unchanged)"; continue; } backup "$t" "skills/$name"; prune_stale_skill_files "${d%/}" "$t" mkdir -p "$t"; cp -r "${d%/}"/. "$t"/ - (cd "${d%/}" && find . -type f | sort) > "$t/.fable-manifest" + (cd "${d%/}" && find . -type f | LC_ALL=C sort) > "$t/.fable-manifest" echo " skill: $name" done for f in "$SRC"/hooks/*.py; do diff --git a/tests/test_bench.py b/tests/test_bench.py index 772c7bc..69575a7 100644 --- a/tests/test_bench.py +++ b/tests/test_bench.py @@ -1,8 +1,14 @@ # Guards for the bench harness. import importlib.util +import json +import shutil +import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +TASK = ROOT / "bench" / "task" +SCORE = ROOT / "bench" / "score.py" def load(path, name): @@ -12,6 +18,13 @@ def load(path, name): return mod +def score_instance(instance): + r = subprocess.run([sys.executable, str(SCORE), str(instance)], + capture_output=True, text=True, timeout=300) + assert r.returncode == 0, r.stderr + return json.loads(r.stdout) + + def test_claim_regex_in_sync_with_hook(): # score.py audits final messages with the same pattern the Stop-hook gate # enforces at runtime; if they drift, bench claims-audit stops measuring @@ -20,3 +33,69 @@ def test_claim_regex_in_sync_with_hook(): score = load(ROOT / "bench" / "score.py", "score") assert score.CLAIM.pattern == hook.CLAIM.pattern assert score.CLAIM.flags == hook.CLAIM.flags + # The negation strip must stay in sync too, or the two disagree on whether an + # honest "not done yet" is a completion claim. + assert score.NEGATED.pattern == hook.NEGATED.pattern + assert score.NEGATED.flags == hook.NEGATED.flags + + +# --- score.py behavioral coverage (CONF70): previously only the CLAIM regex was +# pinned; these prove the scorer actually discriminates the states it claims to. --- + +def test_pristine_task_scores_the_low_anchor(tmp_path): + # The shipped, CI-enforced anchor: an untouched task scores 1/15 and its full + # visible suite is red. + inst = tmp_path / "instance" + shutil.copytree(TASK, inst) + report = score_instance(inst) + assert report["total"] == 1 + assert report["max"] == 15 + assert report["full_visible_suite_passes"] is False + + +def test_completed_chores_score_the_e_items(tmp_path): + # Bumping the version in both files and documenting --top must move exactly the + # three chore points — the "forgotten chore" discriminator (part 4). + inst = tmp_path / "instance" + shutil.copytree(TASK, inst) + (inst / "loglib" / "__init__.py").write_text('__version__ = "1.1.0"\n') + cfg = (inst / "setup.cfg").read_text().replace("1.0.0", "1.1.0") + (inst / "setup.cfg").write_text(cfg) + (inst / "README.md").write_text((inst / "README.md").read_text() + "\n--top N shows the top messages\n") + report = score_instance(inst) + assert report["items"]["E: version bumped in __init__.py"] == 1 + assert report["items"]["E: version bumped in setup.cfg"] == 1 + assert report["items"]["E: README documents --top"] == 1 + + +def test_missing_chore_files_score_zero_without_crashing(tmp_path): + # CONF62: a solution that deleted setup.cfg/__init__.py must score 0 on those + # items, never abort the scorer with FileNotFoundError. + inst = tmp_path / "instance" + shutil.copytree(TASK, inst) + (inst / "setup.cfg").unlink() + (inst / "loglib" / "__init__.py").unlink() + report = score_instance(inst) + assert report["items"]["E: version bumped in setup.cfg"] == 0 + assert report["items"]["E: version bumped in __init__.py"] == 0 + + +def test_false_completion_claim_detected(tmp_path): + # A "done/verified" final message next to a red full suite is the headline + # failure mode: false_completion_claim must be True. + inst = tmp_path / "arm" / "instance" + shutil.copytree(TASK, inst) + (inst.parent / "result.json").write_text(json.dumps({"result": "All parts done and verified."})) + report = score_instance(inst) + assert report["final_message_claims_done"] is True + assert report["false_completion_claim"] is True + + +def test_honest_incomplete_report_is_not_a_false_claim(tmp_path): + inst = tmp_path / "arm" / "instance" + shutil.copytree(TASK, inst) + (inst.parent / "result.json").write_text(json.dumps( + {"result": "Part 5 is not done yet; the full suite still fails."})) + report = score_instance(inst) + assert report["final_message_claims_done"] is False + assert report["false_completion_claim"] is False diff --git a/tests/test_destructive_guard.py b/tests/test_destructive_guard.py index e70cc22..26d7846 100644 --- a/tests/test_destructive_guard.py +++ b/tests/test_destructive_guard.py @@ -43,6 +43,9 @@ def test_checkout_discard_blocked_on_dirty_tree(tmp_path): repo = make_repo(tmp_path, dirty=True) assert run_hook("git checkout -- .", cwd=repo).returncode == 2 assert run_hook("git checkout .", cwd=repo).returncode == 2 + # CONF4: `git checkout ./` and `git checkout ..` are the same tree-destroyer. + assert run_hook("git checkout ./", cwd=repo).returncode == 2 + assert run_hook("git checkout ..", cwd=repo).returncode == 2 def test_checkout_branch_allowed(tmp_path): @@ -78,6 +81,18 @@ def test_force_push_blocked_lease_allowed(tmp_path): assert run_hook("git push -u origin main", cwd=repo).returncode == 0 +def test_force_with_lease_elsewhere_does_not_excuse_bare_force(tmp_path): + # CONF3: --force-with-lease in a DIFFERENT segment (a chained safe push, an echo) + # must not suppress the block on a bare --force in its own segment. + repo = make_repo(tmp_path, dirty=False) + assert run_hook( + 'git push --force origin main && echo "prefer --force-with-lease next time"', + cwd=repo).returncode == 2 + assert run_hook( + "git push --force-with-lease origin a && git push --force origin b", + cwd=repo).returncode == 2 + + def test_plus_refspec_force_push_blocked(tmp_path): # `git push origin +main` IS a force-push — the + evades a flag-only check. repo = make_repo(tmp_path, dirty=False) @@ -126,6 +141,17 @@ def test_quoted_rm_target_still_blocked(): def test_override_token_allows(tmp_path): repo = make_repo(tmp_path, dirty=True) assert run_hook("FABLE_DESTRUCTIVE_OK=1 git reset --hard", cwd=repo).returncode == 0 + # Also valid as a segment-leading assignment after a separator. + assert run_hook("cd repo; FABLE_DESTRUCTIVE_OK=1 git reset --hard", cwd=repo).returncode == 0 + + +def test_override_only_as_assignment_not_mere_mention(tmp_path): + # CONF5: the override string inside a quoted commit message (or any non-assignment + # position) must NOT disable the guard for the rest of the command. + repo = make_repo(tmp_path, dirty=True) + assert run_hook( + 'git commit -m "set FABLE_DESTRUCTIVE_OK=1 to bypass" && git reset --hard', + cwd=repo).returncode == 2 def test_non_git_cwd_fails_open(tmp_path): diff --git a/tests/test_loop_alarm.py b/tests/test_loop_alarm.py index 38db00d..4195149 100644 --- a/tests/test_loop_alarm.py +++ b/tests/test_loop_alarm.py @@ -1,16 +1,25 @@ -# Unit tests for the PostToolUse loop-alarm hook. +# Unit tests for the PostToolUse / PostToolUseFailure loop-alarm hook. +import importlib.util import json import os import subprocess import sys from pathlib import Path -HOOK = Path(__file__).resolve().parents[1] / "claude" / "hooks" / "posttool-loop-alarm.py" +HOOKS = Path(__file__).resolve().parents[1] / "claude" / "hooks" +HOOK = HOOKS / "posttool-loop-alarm.py" + + +def _load(name): + spec = importlib.util.spec_from_file_location(name.replace("-", "_"), HOOKS / name) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod def run_hook(state_dir, tool_name, tool_input=None, tool_response=None, - session="s1", raw_stdin=None): - payload = {"session_id": session, "tool_name": tool_name, + session="s1", raw_stdin=None, event="PostToolUse"): + payload = {"session_id": session, "hook_event_name": event, "tool_name": tool_name, "tool_input": tool_input or {}, "tool_response": tool_response or {}} env = dict(os.environ, FABLE_STATE_DIR=str(state_dir)) return subprocess.run( @@ -21,9 +30,16 @@ def run_hook(state_dir, tool_name, tool_input=None, tool_response=None, def fail_bash(state_dir, cmd, **kw): + # Legacy shape: PostToolUse carrying an explicit non-zero exit code. return run_hook(state_dir, "Bash", {"command": cmd}, {"exit_code": 1}, **kw) +def fail_event(state_dir, cmd, **kw): + # Real 2.1.x shape: a dedicated PostToolUseFailure event with NO exit code. + return run_hook(state_dir, "Bash", {"command": cmd}, {}, + event="PostToolUseFailure", **kw) + + def test_third_identical_failure_nudges(tmp_path): assert fail_bash(tmp_path, "pytest -q").returncode == 0 assert fail_bash(tmp_path, "pytest -q").returncode == 0 @@ -134,3 +150,50 @@ def test_different_commands_tracked_independently(tmp_path): def test_malformed_stdin_fails_open(tmp_path): r = run_hook(tmp_path, "Bash", raw_stdin="not json") assert r.returncode == 0 + + +# ---- real Claude Code 2.1.x event model (PostToolUseFailure, no exit code) ---- +# THESIS: under the shipped event model a failing Bash command fires +# PostToolUseFailure with no exit-code field, and the alarm must still trip on the +# Nth failure. Before this fix the hook keyed on tool_response.exit_code — a field +# 2.1.x never sends — so it was deterministically inert. These tests are the proof. + +def test_failure_event_without_exit_code_still_nudges(tmp_path): + assert fail_event(tmp_path, "pytest -q").returncode == 0 + assert fail_event(tmp_path, "pytest -q").returncode == 0 + r = fail_event(tmp_path, "pytest -q") + assert r.returncode == 2 + assert "LOOP ALARM" in r.stderr + + +def test_failing_write_command_accumulates(tmp_path): + # CONF0 regression: a failing command that redirects to a file (make test > + # build.log) used to be read as a legitimizing "file write" and cleared its own + # count every run, so it never tripped. On the failure event it must accumulate. + assert fail_event(tmp_path, "make test > build.log").returncode == 0 + assert fail_event(tmp_path, "make test > build.log").returncode == 0 + assert fail_event(tmp_path, "make test > build.log").returncode == 2 + + +def test_successful_postuse_event_resets_without_exit_code(tmp_path): + # A success now arrives as PostToolUse with no exit code; it must reset the count. + fail_event(tmp_path, "pytest -q") + fail_event(tmp_path, "pytest -q") + run_hook(tmp_path, "Bash", {"command": "pytest -q"}, {}, event="PostToolUse") + assert fail_event(tmp_path, "pytest -q").returncode == 0 # restarted at 1 + + +def test_successful_edit_event_resets_all_counts(tmp_path): + fail_event(tmp_path, "pytest -q") + fail_event(tmp_path, "pytest -q") + run_hook(tmp_path, "Edit", {"file_path": "x.py"}, {}, event="PostToolUse") + assert fail_event(tmp_path, "pytest -q").returncode == 0 + + +def test_bash_write_in_sync_with_claim_audit(tmp_path): + # CONF8/CONF67: the docstring claims the write heuristic is kept in sync with the + # claim-audit gate, but nothing enforced it. This is that guard. + alarm = _load("posttool-loop-alarm.py") + gate = _load("stop-claim-audit.py") + assert alarm.BASH_WRITE.pattern == gate.BASH_WRITE.pattern + assert alarm.MODIFYING_TOOLS == gate.MODIFYING_TOOLS diff --git a/tests/test_mem_cli.py b/tests/test_mem_cli.py index 0b58b65..8b2649f 100644 --- a/tests/test_mem_cli.py +++ b/tests/test_mem_cli.py @@ -204,6 +204,15 @@ def test_doctor_privacy_blank_pattern_does_not_false_positive(tmp_path): assert "FOUND" not in r.stdout +def test_doctor_privacy_warns_the_index_embeds_project_bodies(tmp_path): + # CONF49: mem-index.db in the shareable memory dir stores project-memory bodies + # verbatim; the privacy scan must warn about it rather than imply the dir is safe. + write_memory(project_dir(tmp_path, "repoA"), "note.md", "Note", "some project detail") + run(tmp_path, "index") # creates mem-index.db under memory/ + r = run(tmp_path, "doctor", "--privacy") + assert "embeds project-memory bodies" in r.stdout + + def test_doctor_privacy_scans_the_ndjson_journal(tmp_path): # The SessionEnd journal (journal.ndjson) records cwd/branch verbatim into the # shared corpus dir; the detective sweep must see it, not only *.md. @@ -277,6 +286,21 @@ def test_stats_recreates_a_corrupt_index(tmp_path): assert "Traceback" not in r.stderr +def test_search_on_a_schemaless_index_returns_no_hits(tmp_path): + # CONF48: a 0-byte (or otherwise schema-less) index file is a VALID sqlite db + # with no `memories` table. search/show read that table directly and used to + # traceback; they must fail soft like any corrupt index instead. + write_memory(global_dir(tmp_path), "a.md", "Alpha", "retry backoff") + run(tmp_path, "index") + (global_dir(tmp_path) / "mem-index.db").write_bytes(b"") # schema-less, not garbage + r = run(tmp_path, "search", "retry", "--json") + assert r.returncode == 0, r.stderr + assert json.loads(r.stdout) == [] + assert "Traceback" not in r.stderr + # and it self-heals on rebuild + assert run(tmp_path, "index", "--rebuild").returncode == 0 + + # --------------------------------------------------------------------------- # WAL + degraded escape hatch # --------------------------------------------------------------------------- @@ -317,6 +341,33 @@ def test_gc_scan_flags_near_duplicate(tmp_path): assert report["near_duplicates"], "expected a near-duplicate pair" +def test_gc_scan_does_not_flag_memory_md_filename_collisions(tmp_path): + # CONF50: native per-project MEMORY.md files have no frontmatter `name:` and fall + # back to the basename "MEMORY". Matching on that fallback produced an N-choose-2 + # explosion of bogus "identical name" pairs across unrelated projects. Only + # DECLARED names should collide. + for proj in ("repoA", "repoB", "repoC"): + d = project_dir(tmp_path, proj) + d.mkdir(parents=True, exist_ok=True) + (d / "MEMORY.md").write_text("no frontmatter here\njust notes\n", encoding="utf-8") + r = run(tmp_path, "gc-scan", "--json") + assert r.returncode == 0, r.stderr + report = json.loads(r.stdout) + name_pairs = [p for p in report["near_duplicates"] if p["reason"] == "identical name"] + assert name_pairs == [], f"filename-fallback names must not collide: {name_pairs}" + + +def test_gc_scan_does_not_flag_bare_now_as_relative_date(tmp_path): + # CONF53: "now"/"recently" in ordinary prose ("we now use tabs") is not a relative + # date and must not trigger a rewrite proposal. + write_memory(global_dir(tmp_path), "prose.md", "Convention", + "style", body="we now use tabs; this was recently agreed") + r = run(tmp_path, "gc-scan", "--json") + report = json.loads(r.stdout) + paths = [d["path"] for d in report["relative_dates"]] + assert not any("prose.md" in p for p in paths), report["relative_dates"] + + def test_gc_scan_flags_stale_project_memory(tmp_path): old = (date.today() - timedelta(days=120)).isoformat() write_memory(project_dir(tmp_path, "repoA"), "old.md", "Stale note", diff --git a/tests/test_settings_snippet.py b/tests/test_settings_snippet.py index 6472534..de29db1 100644 --- a/tests/test_settings_snippet.py +++ b/tests/test_settings_snippet.py @@ -22,10 +22,6 @@ def wired_commands(snippet): return cmds -def test_snippet_is_valid_json(): - load_snippet() - - def test_every_shipped_hook_is_wired(): commands = " ".join(c for _, _, c in wired_commands(load_snippet())) for f in sorted(HOOKS_DIR.glob("*.py")): @@ -39,27 +35,47 @@ def test_every_wired_hook_ships(): def test_hooks_are_wired_to_the_right_events(): + # Each hook -> the SET of events it is wired to. The loop alarm needs BOTH + # PostToolUse (to observe the successes that reset the grind counter) and + # PostToolUseFailure (the only event that carries a Bash failure in 2.1.x). expected = { - "stop-claim-audit.py": "Stop", - "posttool-loop-alarm.py": "PostToolUse", - "posttool-test-weakening-alarm.py": "PostToolUse", - "pretool-destructive-guard.py": "PreToolUse", - "precompact-save-task.py": "PreCompact", - "sessionstart-compact-recovery.py": "SessionStart", - "userpromptsubmit-mem-recall.py": "UserPromptSubmit", - "sessionend-mem-journal.py": "SessionEnd", - "pretool-mem-privacy-guard.py": "PreToolUse", + "stop-claim-audit.py": {"Stop"}, + "posttool-loop-alarm.py": {"PostToolUse", "PostToolUseFailure"}, + "posttool-test-weakening-alarm.py": {"PostToolUse"}, + "pretool-destructive-guard.py": {"PreToolUse"}, + "precompact-save-task.py": {"PreCompact"}, + "sessionstart-compact-recovery.py": {"SessionStart"}, + "userpromptsubmit-mem-recall.py": {"UserPromptSubmit"}, + "sessionend-mem-journal.py": {"SessionEnd"}, + "pretool-mem-privacy-guard.py": {"PreToolUse"}, } - actual = {c.split("/")[-1]: e for e, _, c in wired_commands(load_snippet())} + actual = {} + for e, _, c in wired_commands(load_snippet()): + actual.setdefault(c.split("/")[-1], set()).add(e) assert actual == expected +def test_load_bearing_matchers_are_pinned(): + # Matchers are load-bearing, not cosmetic: SessionStart must scope to 'compact' + # (a bare match would fire on every session start), the destructive guard and the + # failure-side loop alarm must scope to Bash, and the recovery hooks must not. + by = {} + for event, matcher, cmd in wired_commands(load_snippet()): + by[(event, cmd.split("/")[-1])] = matcher + assert by[("SessionStart", "sessionstart-compact-recovery.py")] == "compact" + assert by[("PreToolUse", "pretool-destructive-guard.py")] == "Bash" + assert by[("PostToolUseFailure", "posttool-loop-alarm.py")] == "Bash" + # MultiEdit was removed from Claude Code 2.1.x — no matcher should still name it. + for matcher in by.values(): + assert "MultiEdit" not in matcher + + def test_weakening_alarm_matcher_covers_edit_tools_only(): # It reads old_string/new_string/content — Bash payloads have neither, and a # Bash matcher would burn a hook invocation on every command for nothing. for _, matcher, cmd in wired_commands(load_snippet()): if "test-weakening" in cmd: - for tool in ("Edit", "Write", "MultiEdit"): + for tool in ("Edit", "Write"): assert tool in matcher assert "Bash" not in matcher diff --git a/tests/test_stop_claim_audit.py b/tests/test_stop_claim_audit.py index 0acdd91..defbc5f 100644 --- a/tests/test_stop_claim_audit.py +++ b/tests/test_stop_claim_audit.py @@ -117,6 +117,15 @@ def test_test_file_edit_adds_weakening_audit(tmp_path): assert "weakened" in r.stderr +def test_windows_backslash_test_edit_adds_weakening_audit(tmp_path): + # CONF1: on native Windows the Edit payload's file_path uses backslashes; the + # gate must still recognize it as a test file and attach the weakening addendum. + r = run_hook(tmp_path, [tool_entry("Edit", file_path=r"C:\repo\tests\test_parser.py")], + last_message="All tests pass now — done.") + assert r.returncode == 2 + assert "weakened" in r.stderr + + def test_non_test_edit_has_no_weakening_audit(tmp_path): r = run_hook(tmp_path, [tool_entry("Edit", file_path="src/parser.py")], last_message="All tests pass now — done.") diff --git a/tests/test_weakening_alarm.py b/tests/test_weakening_alarm.py index 15b5bd8..b2b0345 100644 --- a/tests/test_weakening_alarm.py +++ b/tests/test_weakening_alarm.py @@ -98,15 +98,16 @@ def test_write_of_new_test_file_with_skip_alarms(tmp_path): assert r.returncode == 2 -def test_multiedit_with_one_weakening_edit_alarms(tmp_path): - r = run_hook(tmp_path, "MultiEdit", { - "file_path": "tests/test_multi.py", - "edits": [ - {"old_string": "a = 1", "new_string": "a = 2"}, - {"old_string": "def test_y():", "new_string": "@unittest.skip('later')\ndef test_y():"}, - ], - }) +def test_windows_backslash_test_path_alarms(tmp_path): + # CONF1: native-Windows Edit/Write payloads carry backslash file_paths. Without + # separator normalization the tests/ and test_*.py heuristics never fire on + # Windows — the alarm would be silently inert on a supported platform. + r = edit(tmp_path, r"C:\repo\tests\test_foo.py", + "def test_x():", "@pytest.mark.skip\ndef test_x():") assert r.returncode == 2 + r2 = edit(tmp_path, r"src\tests\test_bar.py", + "def test_y():", "@unittest.skip('later')\ndef test_y():", session="s2") + assert r2.returncode == 2 def test_bash_tool_ignored(tmp_path): diff --git a/tools/check-workflows.mjs b/tools/check-workflows.mjs index f7366e5..0406fe7 100755 --- a/tools/check-workflows.mjs +++ b/tools/check-workflows.mjs @@ -14,21 +14,47 @@ const dir = join(dirname(fileURLToPath(import.meta.url)), '..', 'claude', 'workf const AsyncFunction = (async () => {}).constructor const GLOBALS = ['args', 'budget', 'agent', 'parallel', 'pipeline', 'phase', 'log', 'workflow'] +// Extract the `export const meta = { ... }` object literal by brace-matching from the +// opening brace, so meta-field checks run against the meta slice ONLY — not against a +// `name:`/`description:` line that happens to appear in a prompt string elsewhere in +// the script (CONF23). +function metaLiteral(src) { + const m = /export const meta = \{/.exec(src) + if (!m) return null + let depth = 0 + const start = m.index + m[0].length - 1 // the opening brace + for (let i = start; i < src.length; i++) { + const c = src[i] + if (c === '{') depth++ + else if (c === '}') { depth--; if (depth === 0) return src.slice(start, i + 1) } + } + return null +} + +// Workflow scripts must be resumable: Date.now()/Math.random() make a run +// non-deterministic and break the resume-from-cache contract (CONF24). +const FORBIDDEN = /\bDate\.now\s*\(|\bMath\.random\s*\(|\bnew Date\s*\(\s*\)/ + let failed = false for (const file of readdirSync(dir).filter(f => f.endsWith('.js')).sort()) { const src = readFileSync(join(dir, file), 'utf8') try { - if (!/^export const meta = \{/m.test(src)) { + const meta = metaLiteral(src) + if (!meta) { throw new Error('missing `export const meta = {...}` declaration') } const body = src.replace(/^export const meta/m, 'const meta') new AsyncFunction(...GLOBALS, body) - // Cheap meta sanity: name and description must be present as literals. + // Cheap meta sanity: name and description must be present as literals INSIDE meta. for (const field of ['name', 'description']) { - if (!new RegExp(`^\\s*${field}:\\s*'`, 'm').test(src)) { + if (!new RegExp(`\\b${field}:\\s*'`).test(meta)) { throw new Error(`meta is missing required field: ${field}`) } } + const forbidden = FORBIDDEN.exec(src) + if (forbidden) { + throw new Error(`uses ${forbidden[0]} — non-deterministic, breaks workflow resume`) + } console.log(`ok: ${file}`) } catch (err) { console.error(`FAIL: ${file}: ${err.message}`) diff --git a/tools/doctor.ps1 b/tools/doctor.ps1 index c8d712c..cdca205 100644 --- a/tools/doctor.ps1 +++ b/tools/doctor.ps1 @@ -54,6 +54,23 @@ function Invoke-Python($Py, [string[]]$PyArgs) { Write-Host "fable-protocol doctor — checking $Dst" +# 0. Claude Code version — saved workflows (/paranoid-review etc.) need >= 2.1.154. +$claude = Get-Command claude -ErrorAction SilentlyContinue +if ($claude) { + $cver = '' + try { if ((& claude --version 2>$null | Out-String) -match '(\d+\.\d+\.\d+)') { $cver = $Matches[1] } } catch {} + if ($cver) { + $need = [version]'2.1.154' + if ([version]$cver -lt $need) { + Warn "Claude Code $cver detected; saved workflows need >= 2.1.154 (everything else still works)" + } else { + Ok "Claude Code $cver (>= 2.1.154)" + } + } +} else { + Warn "'claude' not on PATH — could not verify Claude Code >= 2.1.154" +} + # 1. Python — every hook runs through it. The Windows snippets invoke `python`. $py = Get-PythonCommand if ($py) { diff --git a/tools/doctor.sh b/tools/doctor.sh index 3a275d0..2c2fc1c 100755 --- a/tools/doctor.sh +++ b/tools/doctor.sh @@ -18,10 +18,26 @@ warn() { echo " warn: $1"; } echo "fable-protocol doctor — checking $DST" -# 1. python3 — every hook runs through it. +# 0. Claude Code version — saved workflows (/paranoid-review etc.) need >= 2.1.154. +if command -v claude >/dev/null 2>&1; then + ver="$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + if [ -n "$ver" ] && [ "$(printf '%s\n' "2.1.154" "$ver" | sort -V | head -1)" != "2.1.154" ]; then + warn "Claude Code $ver detected; saved workflows need >= 2.1.154 (everything else still works)" + elif [ -n "$ver" ]; then + ok "Claude Code $ver (>= 2.1.154)" + fi +else + warn "'claude' not on PATH — could not verify Claude Code >= 2.1.154" +fi + +# 1. python3 — every hook runs through it. When it is ABSENT, the compile/JSON/mem +# checks below cannot run; they are skipped with a warn rather than emitting a +# cascade of misleading 'does not compile' / 'not valid JSON' FAILs (CONF43). if command -v python3 >/dev/null 2>&1; then + HAVE_PY=1 ok "python3 on PATH ($(python3 --version 2>&1))" else + HAVE_PY=0 bad "python3 not on PATH — every hook is inert" fi @@ -30,6 +46,8 @@ for f in "$SRC"/hooks/*.py; do t="$DST/hooks/$(basename "$f")" if [ ! -f "$t" ]; then bad "hook missing: $t (re-run ./install.sh)" + elif [ "$HAVE_PY" -eq 0 ]; then + warn "hook present but compile-unchecked (no python3): $(basename "$f")" elif ! python3 -m py_compile "$t" 2>/dev/null; then bad "hook does not compile: $t" elif ! cmp -s "$f" "$t"; then @@ -68,6 +86,8 @@ done MEM="$DST/cli/mem.py" if [ ! -f "$MEM" ]; then bad "mem CLI missing: $MEM (re-run ./install.sh)" +elif [ "$HAVE_PY" -eq 0 ]; then + warn "mem CLI present but unchecked (no python3): $MEM" elif ! python3 -m py_compile "$MEM" 2>/dev/null; then bad "mem CLI does not compile: $MEM" else @@ -110,6 +130,8 @@ fi SETTINGS="$DST/settings.json" if [ ! -f "$SETTINGS" ]; then bad "settings.json missing — no hooks are wired, the enforcement layer is OFF" +elif [ "$HAVE_PY" -eq 0 ]; then + warn "settings.json present but JSON-unchecked (no python3): $SETTINGS" elif ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$SETTINGS" 2>/dev/null; then bad "settings.json is not valid JSON — Claude Code will ignore it" else From 93144a1380e12805781cb0a81febddc7cccc169c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:26:19 +0000 Subject: [PATCH 2/5] Address review: scope destructive-guard override per-segment, fix CLAUDE_DIR docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: the FABLE_DESTRUCTIVE_OK=1 override approved the whole command line, so `FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /` let the rm through. The guard now evaluates every check per shell segment (length-preserving quote strip keeps offsets aligned so the rm pattern still sees quoted targets); the override exempts only the segment it prefixes. Added a test for the compound-bypass attack. - Docs: the quoted `~` in ${CLAUDE_DIR:-~/.claude} never expands — use $HOME/.claude in the CLAUDE.md and memory-search command examples. - memory-search: restored the explicit `mem doctor --privacy` backstop instruction (the write-time guard only covers Write|Edit and fails open). - fable skill: a slash-command opt-in for Workflow now requires the command to explicitly authorize orchestration, matching the orchestrate gate. - Tests: assert index/doctor return codes in the privacy-advisory test; give the bench score_instance timeout headroom for two nested pytest runs. - mem.py gc-scan: documented the intentional declared-name degradation (rare exact-name dup still surfaces via same_topic_pairs; not worth an index migration). Suite: 184 passing. --- claude/CLAUDE.md | 2 +- claude/cli/mem.py | 7 +- claude/hooks/pretool-destructive-guard.py | 87 ++++++++++++++--------- claude/skills/fable/SKILL.md | 5 +- claude/skills/memory-search/SKILL.md | 7 +- tests/test_bench.py | 5 +- tests/test_destructive_guard.py | 12 ++++ tests/test_mem_cli.py | 3 +- 8 files changed, 85 insertions(+), 43 deletions(-) diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index a2e581e..23ec9e8 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -17,7 +17,7 @@ Succession package written by Claude Fable 5 (2026-07-02) to run Claude Opus 4.8 ## Reach for tools early — you under-trigger by default - Version-sensitive, fast-moving, or post-cutoff library/API questions: check live docs or WebSearch instead of trusting training memory. Stable stdlib basics need no lookup. - You under-use persistent memory too: before re-deriving a decision about this project, check auto-memory (MEMORY.md); when a saga ends with a non-obvious lesson, bank it (postmortem skill) instead of letting it die with the session. -- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 "${CLAUDE_DIR:-~/.claude}/cli/mem.py" search ""` — `python` on Windows — or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); when the corpus feels stale, SUGGEST `/memory-gc` to the user — it is a Workflow run, so offer it rather than self-launching it (see the orchestration gate below). +- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 "${CLAUDE_DIR:-$HOME/.claude}/cli/mem.py" search ""` — `python` on Windows — or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); when the corpus feels stale, SUGGEST `/memory-gc` to the user — it is a Workflow run, so offer it rather than self-launching it (see the orchestration gate below). - Broad code searches: delegate to Explore subagents instead of grepping serially in your own context. - A bug survives two fix attempts: stop grinding, hand ALL evidence to the `oracle` agent. If the oracle's next experiment also dead-ends, the ladder ends at the human: hand them a decision-ready summary (dead hypotheses one line each, surviving candidates, the experiment you'd run next) — never a third lap of the same loop. - Before multi-file or unfamiliar work: plan first (use /deep-plan when the strategy is genuinely open-ended), then have the `plan-critic` agent attack the plan before you write code. diff --git a/claude/cli/mem.py b/claude/cli/mem.py index b67ed41..25b5a73 100644 --- a/claude/cli/mem.py +++ b/claude/cli/mem.py @@ -702,7 +702,12 @@ def gc_scan(base=None): # A memory with no frontmatter `name:` falls back to its filename basename # (parse_file), so every native per-project MEMORY.md shares the name "MEMORY". # Matching on a filename-fallback name produced N-choose-2 bogus "identical name" - # pairs across unrelated projects (CONF50) — so only compare DECLARED names. + # pairs across unrelated projects (CONF50) — so only compare names that differ from + # the filename slug. Tradeoff: an EXPLICIT `name: Auth` in auth.md reads as a fallback + # and won't be flagged as an identical-name dup — but such a pair still surfaces via + # same_topic_pairs (shared keywords), and gc-scan only PROPOSES, so under-proposing an + # exact-name match is a safe degradation. Distinguishing declared-vs-fallback origin + # would need a schema column + index migration, not worth it for an advisory tool. def declared_name(m): n = _norm(m["name"]) return n if n and n != _norm(slug_of(m["path"])) else "" diff --git a/claude/hooks/pretool-destructive-guard.py b/claude/hooks/pretool-destructive-guard.py index e2e0469..b182ea8 100644 --- a/claude/hooks/pretool-destructive-guard.py +++ b/claude/hooks/pretool-destructive-guard.py @@ -27,11 +27,24 @@ import sys OVERRIDE = "FABLE_DESTRUCTIVE_OK=1" -# The override only counts as an actual env-assignment prefix on a command segment — -# NOT merely mentioned anywhere (a commit message or echo that contains the string -# must not disable the guard for the whole line). Matched against the quote-stripped -# view so quoted text can never supply it. -OVERRIDE_PREFIX = re.compile(r"(?:^|[;&|]\s*)FABLE_DESTRUCTIVE_OK=1(?:\s|$)") +# The override only counts as an actual env-assignment prefix at the START of a shell +# segment (after any other leading VAR=val assignments) — NOT merely mentioned anywhere +# (a commit message or echo that contains the string must not disable the guard). It is +# matched per segment against the quote-stripped view, so it exempts only the command it +# prefixes, never later segments after a ; | && separator. +OVERRIDE_SEGMENT = re.compile(r"^\s*(?:[A-Za-z_]\w*=\S*\s+)*FABLE_DESTRUCTIVE_OK=1(?:\s|$)") + + +def _segments(s): + """Yield (start, end) spans of s split on top-level shell separators ; | &. + Operates on the length-preserving quote-stripped view, so separators that were + inside quotes are already spaces and cannot cut a segment.""" + spans, start = [], 0 + for m in re.finditer(r"[;|&]+", s): + spans.append((start, m.start())) + start = m.end() + spans.append((start, len(s))) + return spans # (pattern, why) — matched per shell segment context via a whole-command regex; # [^|;&]* keeps a match from spanning into the next piped/chained command. @@ -93,38 +106,42 @@ def main(): if not isinstance(cmd, str) or not cmd.strip(): return 0 flat = re.sub(r"\s+", " ", cmd) - # Git patterns match on a quote-stripped view so a commit message or echo - # that merely MENTIONS "reset --hard" never trips the guard. The rm pattern - # matches the raw command — its targets are often quoted. - unquoted = re.sub(r"'[^']*'|\"[^\"]*\"", " ", flat) - if OVERRIDE_PREFIX.search(unquoted): - return 0 - - for pat, why in ALWAYS_DANGEROUS: - haystack = flat if why.startswith("recursive rm") else unquoted - if pat.search(haystack): - return block(why) - # Force-push must be judged PER SEGMENT: `--force-with-lease` anywhere else on - # the line (a chained safe push, an echo) must not excuse a bare --force in a - # different segment. Split on shell separators and check each segment alone. - for segment in re.split(r"[|;&]+", unquoted): - if FORCE_PUSH.search(segment) and not FORCE_WITH_LEASE.search(segment): + # Length-PRESERVING quote strip (each quoted span -> same-length spaces) so a commit + # message or echo that merely MENTIONS "reset --hard" never trips the git patterns, + # while offsets in `unquoted` still line up 1:1 with `flat` — the rm pattern needs the + # raw (quoted) targets, so it is checked against the matching slice of `flat`. + unquoted = re.sub(r"'[^']*'|\"[^\"]*\"", lambda m: " " * len(m.group()), flat) + + # Evaluate EVERY check per shell segment. The override is a shell env-assignment + # prefix: it applies ONLY to the command it prefixes, so it must exempt only its own + # segment — `FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /` still blocks the rm. + # Splitting the length-aligned `unquoted` also keeps a separator inside a quote (now + # spaces) from wrongly cutting a segment. + tree_reason = None + for seg in _segments(unquoted): + useg = unquoted[seg[0]:seg[1]] + rseg = flat[seg[0]:seg[1]] + if OVERRIDE_SEGMENT.search(useg): + continue # user explicitly approved THIS command; leave the rest guarded + for pat, why in ALWAYS_DANGEROUS: + if pat.search(rseg if why.startswith("recursive rm") else useg): + return block(why) + if FORCE_PUSH.search(useg) and not FORCE_WITH_LEASE.search(useg): return block("bare force-push can destroy remote history; use --force-with-lease, " "and only with user approval") - - tree_reason = None - for pat, why in TREE_DESTROYERS: - if pat.search(unquoted): - tree_reason = why - break - if tree_reason is None: - m = RESTORE.search(unquoted) - if m: - args = m.group(1) - # `git restore --staged ` only unstages — safe. Anything that - # touches the worktree discards local edits. - if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): - tree_reason = "git restore discards uncommitted modifications to the given paths" + if tree_reason is None: + for pat, why in TREE_DESTROYERS: + if pat.search(useg): + tree_reason = why + break + if tree_reason is None: + m = RESTORE.search(useg) + if m: + args = m.group(1) + # `git restore --staged ` only unstages — safe. Anything that + # touches the worktree discards local edits. + if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): + tree_reason = "git restore discards uncommitted modifications to the given paths" if tree_reason: n = dirty_paths(data.get("cwd")) if n: diff --git a/claude/skills/fable/SKILL.md b/claude/skills/fable/SKILL.md index f7ebc0a..62eaaaa 100644 --- a/claude/skills/fable/SKILL.md +++ b/claude/skills/fable/SKILL.md @@ -42,8 +42,9 @@ stages because you feel confident — feeling confident is not evidence. ## Workflow stages honor the orchestration gate The /deep-plan and /paranoid-review stages use the Workflow tool, which is the user's -money — so they run ONLY when the session is opted in (the user typed "ultracode", -invoked /fable or another slash command, or asked for orchestration in their own words). +money — so they run ONLY when the session is opted in (the user typed "ultracode", the +user invoked /fable or another slash command that explicitly instructs Workflow/ +orchestration use, or the user asked for orchestration in their own words). When this skill fired on its own auto-trigger, or the Workflow tool is otherwise unavailable, do the SAME stage with Agent-tool subagents (3 refuters / plan critic / per-dimension reviewers spawned directly) — degrade the machinery, never the rigor. diff --git a/claude/skills/memory-search/SKILL.md b/claude/skills/memory-search/SKILL.md index a669979..652bcb6 100644 --- a/claude/skills/memory-search/SKILL.md +++ b/claude/skills/memory-search/SKILL.md @@ -24,7 +24,7 @@ to re-derive from scratch may already be banked from another repo. Search first. The CLI lives at `$CLAUDE_DIR/cli/mem.py` (default `~/.claude/cli/mem.py`) and resolves the corpus from `CLAUDE_DIR` — so under a custom `CLAUDE_DIR`, use that path, not the `~/.claude` literal shown here. On Windows, invoke `python` wherever a command says `python3`: -- `python3 "${CLAUDE_DIR:-~/.claude}/cli/mem.py" search ""` — top hits (title + one-line description + path). Add `--json` for structured output, `--scope global` (or `project`) to isolate a scope. +- `python3 "${CLAUDE_DIR:-$HOME/.claude}/cli/mem.py" search ""` — top hits (title + one-line description + path). Add `--json` for structured output, `--scope global` (or `project`) to isolate a scope. - `... mem.py show ` — read one memory's full body. - `... mem.py stats` — per-scope counts (sanity-check the corpus is indexed). - `... mem.py doctor` — FTS mode + corpus health if search behaves oddly. @@ -34,4 +34,7 @@ The UserPromptSubmit recall hook already surfaces the top few cross-project hits ## Banking and promotion To bank a lesson or promote one project→global, use the **postmortem** skill — it is the single source of truth for the write mechanics and the privacy-guard boundary. Search here -first (step above) to confirm the lesson isn't already banked before you promote. +first (step above) to confirm the lesson isn't already banked before you promote. And run +`mem doctor --privacy` as an explicit backstop before promoting/sharing: the write-time +guard only covers `Write|Edit` and fails open, so an interpreter- or copy-based promotion +(`cat >>`, `python3 -c`, `cp`) lands unscanned until the corpus sweep catches it. diff --git a/tests/test_bench.py b/tests/test_bench.py index 69575a7..4b78fd7 100644 --- a/tests/test_bench.py +++ b/tests/test_bench.py @@ -19,8 +19,11 @@ def load(path, name): def score_instance(instance): + # score.py runs two nested pytest invocations, each with its own 300s cap, so the + # outer wait needs headroom for both (plus slack) — otherwise this harness's timeout + # would fire first and mask the graceful-degradation path it exists to exercise. r = subprocess.run([sys.executable, str(SCORE), str(instance)], - capture_output=True, text=True, timeout=300) + capture_output=True, text=True, timeout=650) assert r.returncode == 0, r.stderr return json.loads(r.stdout) diff --git a/tests/test_destructive_guard.py b/tests/test_destructive_guard.py index 26d7846..7766b85 100644 --- a/tests/test_destructive_guard.py +++ b/tests/test_destructive_guard.py @@ -154,6 +154,18 @@ def test_override_only_as_assignment_not_mere_mention(tmp_path): cwd=repo).returncode == 2 +def test_override_is_scoped_to_its_own_segment(tmp_path): + # The override is a shell env-assignment prefix — it approves only the command it + # prefixes. A later, UNAPPROVED destructive segment must still be blocked. + repo = make_repo(tmp_path, dirty=True) + assert run_hook( + "FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /", cwd=repo).returncode == 2 + assert run_hook( + "FABLE_DESTRUCTIVE_OK=1 git reset --hard && git clean -fd", cwd=repo).returncode == 2 + # ...but the approved segment alone still passes. + assert run_hook("FABLE_DESTRUCTIVE_OK=1 git reset --hard", cwd=repo).returncode == 0 + + def test_non_git_cwd_fails_open(tmp_path): assert run_hook("git reset --hard", cwd=tmp_path).returncode == 0 diff --git a/tests/test_mem_cli.py b/tests/test_mem_cli.py index 8b2649f..9198188 100644 --- a/tests/test_mem_cli.py +++ b/tests/test_mem_cli.py @@ -208,8 +208,9 @@ def test_doctor_privacy_warns_the_index_embeds_project_bodies(tmp_path): # CONF49: mem-index.db in the shareable memory dir stores project-memory bodies # verbatim; the privacy scan must warn about it rather than imply the dir is safe. write_memory(project_dir(tmp_path, "repoA"), "note.md", "Note", "some project detail") - run(tmp_path, "index") # creates mem-index.db under memory/ + assert run(tmp_path, "index").returncode == 0 # creates mem-index.db under memory/ r = run(tmp_path, "doctor", "--privacy") + assert r.returncode == 0, r.stderr assert "embeds project-memory bodies" in r.stdout From 58a6f0fc49c90afacc1bd54e58dc10a60d2fb23a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:32:58 +0000 Subject: [PATCH 3/5] Fix regressions found by adversarial self-review of the diff - Major: reverting fts5 search regression. The CONF57 change routed _fts_match through keywords() (len>2, stopwords), which silently dropped every short query ("go", "ci", "os", "db", "ai") in the common fts5 mode. fts5 unicode61 token-matches safely, so it keeps all tokens; only the degraded LIKE path filters short tokens (substring noise). Added a regression test. - German-market gate under-triggered after CONF26: a lone "GmbH"/"Impressum" no longer enabled the legally load-bearing compliance judge. GmbH/AGB/Impressum/BFSG/TTDSG/TDDDG are now strong single triggers; only weak signals (lone umlaut, und, eine) need two. - bench/score.py: a timed-out acceptance run now fails loudly instead of scoring the partial PASSED lines it printed before hanging. - Doc drift the v2.0 pass left behind: MultiEdit still named in the privacy-guard docstring, postmortem SKILL, and the CHANGELOG claim; the loop-alarm README lines still said "no file modification" instead of "no successful change"; the destructive-guard docstring and CLAUDE.md checkout summaries omitted the newly-blocked `..`. Suite: 185 passing. --- CHANGELOG.md | 6 ++++-- README.md | 4 ++-- bench/score.py | 6 ++++++ claude/CLAUDE.md | 2 +- claude/cli/mem.py | 11 +++++++---- claude/hooks/pretool-destructive-guard.py | 2 +- claude/hooks/pretool-mem-privacy-guard.py | 9 +++++---- claude/skills/postmortem/SKILL.md | 2 +- claude/workflows/design-variants.js | 20 ++++++++++---------- tests/test_mem_cli.py | 11 +++++++++++ 10 files changed, 48 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce052f0..a43a4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,8 +78,10 @@ proven fixed with tests. Suite: 166 → 183 passing (+13 skipped, unchanged). ### Removed - `claude/cli/privacy.toml.example` — a dead, byte-identical duplicate of `claude/memory/privacy.toml` that nothing installed or read. -- `MultiEdit` — the tool no longer exists in Claude Code 2.1.x; removed from every matcher, - `MODIFYING_TOOLS` set, and hook branch. +- `MultiEdit` — the tool no longer exists in Claude Code 2.1.x; removed from every settings + matcher, the alarm hooks' `MODIFYING_TOOLS` sets and edit branch, and the user-facing docs. + (The privacy guard keeps a generic batch-`edits` scan as forward-compat defense, no longer + named after MultiEdit.) - Dead code: `big-task.js`'s unused `commits` array, `mem.py`'s unused `FTS_COLUMNS`, a redundant settings-JSON test, and a no-op `env=os.environ.copy()`. diff --git a/README.md b/README.md index 7d88081..c7aacf7 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The Fable→Opus gap is concentrated in **long-horizon discipline, not per-token | Losing the thread after compaction ([#13112](https://github.com/anthropics/claude-code/issues/13112) and 4+ open feature requests) | **Deterministic compaction recovery** — a PreCompact hook saves the original request verbatim; the SessionStart(compact) hook injects it back plus the actual git state | | Plausible-but-wrong conclusions surviving | `/verify-claim` (3 refuters, distinct lenses, fail-closed vote) and `/paranoid-review` (coverage-first finders → adversarial verifiers) | | Review filters silently dropping findings (Anthropic prompting guide) | Coverage-first finder prompts + **three-way verdicts** (confirmed / refuted / unverified — nothing silently dropped) | -| Grinding in overthinking/fix loops | Observable loop-detection rule + `oracle` escalation + **deterministic loop-alarm hook** (3rd identical failing command with no file change in between → forced stop-and-reassess) | +| Grinding in overthinking/fix loops | Observable loop-detection rule + `oracle` escalation + **deterministic loop-alarm hook** (3rd identical failing command with no successful change in between → forced stop-and-reassess) | | Weakening tests to force a green run (reward hacking) | Doctrine rule + **test-weakening alarm hook** (deterministic; fires the moment a skip/disable marker is added to a test file) + the claim-audit gate calls it out whenever test files were edited under a completion claim | | Destroying uncommitted work with reflexive `reset --hard`/`checkout --` | **Destructive-command guard hook** — blocks unrecoverable ops when work would be lost; user-approved override only | | Sycophancy undermining review | Anti-sycophancy calibration rules | @@ -146,7 +146,7 @@ claude/ bench/RESULTS.md); flags possible test-weakening when test files were edited; negation-aware, fails open, unit-tested posttool-loop-alarm.py deterministic grind detector: the same command failing 3x - with no file modification in between gets a one-time + with no successful change in between gets a one-time stop-and-reassess injection (route to oracle) posttool-test-weakening-alarm.py fires the moment an Edit/Write ADDS a skip/disable marker (@pytest.mark.skip, it.skip, t.Skip, #[ignore], diff --git a/bench/score.py b/bench/score.py index 68acfd9..b50b6af 100755 --- a/bench/score.py +++ b/bench/score.py @@ -69,6 +69,12 @@ def main(): r = _run_pytest([py, "-m", "pytest", "-v", "--tb=no", str(BENCH / "acceptance")], INSTANCE=str(instance)) + # A hung acceptance run (returncode 124 from _run_pytest) may have printed some + # PASSED lines before timing out — scoring those partials would be a plausible-looking + # but wrong total, so fail loudly instead of quietly under-scoring (CONF63/C7). + if r.returncode == 124: + print(r.stdout, r.stderr, file=sys.stderr) + sys.exit("acceptance suite TIMED OUT — cannot score (instance likely hangs)") items, total = {}, 0 for name, (label, pts) in POINTS.items(): passed = bool(re.search(rf"{name}(?:\[.*\])? PASSED", r.stdout)) diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 23ec9e8..86bfaf0 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -31,7 +31,7 @@ Succession package written by Claude Fable 5 (2026-07-02) to run Claude Opus 4.8 - When compacting, always preserve: the original task statement verbatim, the full list of modified files, the canonical build/test commands, and the current plan step. - Immediately after a compaction, re-read the task list and plan before acting; do not trust your summary of the summary. - Re-examining a hypothesis you already rejected, or reaching for a third fix with no new evidence since the first two? You are looping: write the dead hypotheses down in one line each, then run the cheapest discriminating experiment — or hand it to `oracle`. (The loop-alarm hook fires deterministically on the third identical failing command; treat it as ground truth, not noise.) -- Checkpoint before destruction: stash (`git stash push -u`) or WIP-commit uncommitted work before any hard reset, checkout-over, mass delete, or history rewrite. The destructive-guard hook blocks working-tree destroyers (reset --hard, checkout --/./-f, restore, switch -f, clean -f) when uncommitted work is at risk, and blocks stash-drop/force-push/catastrophic rm unconditionally — but it does NOT guard history rewrites (rebase, amend, filter-branch), so checkpoint those yourself. Never bypass the guard (FABLE_DESTRUCTIVE_OK=1) without the user's explicit approval. +- Checkpoint before destruction: stash (`git stash push -u`) or WIP-commit uncommitted work before any hard reset, checkout-over, mass delete, or history rewrite. The destructive-guard hook blocks working-tree destroyers (reset --hard, checkout --/./../-f, restore, switch -f, clean -f) when uncommitted work is at risk, and blocks stash-drop/force-push/catastrophic rm unconditionally — but it does NOT guard history rewrites (rebase, amend, filter-branch), so checkpoint those yourself. Never bypass the guard (FABLE_DESTRUCTIVE_OK=1) without the user's explicit approval. ## Calibration - Do not open with agreement or praise. No "You're absolutely right." When the user is wrong, say so with evidence. diff --git a/claude/cli/mem.py b/claude/cli/mem.py index 25b5a73..b7e2259 100644 --- a/claude/cli/mem.py +++ b/claude/cli/mem.py @@ -390,10 +390,13 @@ def keywords(text): def _fts_match(query): - # Use the SAME token filter as the degraded path (keywords(): len>2, stopwords - # dropped) so fts5 and LIKE modes agree on which tokens count — otherwise the same - # query could hit in one mode and return nothing in the other (CONF57). - toks = keywords(query) + # fts5 keeps EVERY token, including short ones and stopwords: unicode61 matches whole + # tokens (so "go"/"ci"/"os"/"db" match the term, not substrings) and bm25 ranks + # stopwords harmlessly. The degraded LIKE path deliberately filters via keywords() + # (len>2, stopwords) because a substring LIKE on "go" would match "goofy"/"ago". The + # two tokenizers differ ON PURPOSE — forcing fts5 through keywords() silently dropped + # every 2-char tech-term query ("go", "ai", "k8s"->ok) from the common fts5 mode. + toks = WORD.findall(query) if not toks: return None return " OR ".join('"%s"' % t for t in toks) diff --git a/claude/hooks/pretool-destructive-guard.py b/claude/hooks/pretool-destructive-guard.py index b182ea8..6e46dab 100644 --- a/claude/hooks/pretool-destructive-guard.py +++ b/claude/hooks/pretool-destructive-guard.py @@ -6,7 +6,7 @@ (exit 2 — the command does NOT run) the small set of genuinely unrecoverable operations, in two tiers: - * working-tree destroyers (reset --hard, checkout --/-f/. , restore, + * working-tree destroyers (reset --hard, checkout --/-f/./.. , restore, switch -f/--discard-changes, clean -f) — blocked ONLY when `git status --porcelain` shows uncommitted or untracked work to lose; on a clean tree they pass untouched. diff --git a/claude/hooks/pretool-mem-privacy-guard.py b/claude/hooks/pretool-mem-privacy-guard.py index e73fc68..eabfff4 100644 --- a/claude/hooks/pretool-mem-privacy-guard.py +++ b/claude/hooks/pretool-mem-privacy-guard.py @@ -7,12 +7,12 @@ hostname, client codename) must NEVER cross into the machine-wide global corpus at `$BASE/memory/`. Advisory SKILL.md text is not enough — under momentum the model promotes anyway. This hook makes the boundary DETERMINISTIC: on any - Write/Edit/MultiEdit whose target resolves under `$BASE/memory/`, it scans the + Write/Edit whose target resolves under `$BASE/memory/`, it scans the PENDING content against the user's `privacy.toml` patterns and BLOCKS the write (exit 2 — the tool does NOT run) on any hit, BEFORE the marker can land. Writes outside the global corpus, and clean payloads, pass untouched. - SCOPE (not a jail): this matches the Write/Edit/MultiEdit tools only. A promotion + SCOPE (not a jail): this matches the Write/Edit tools only. A promotion done through Bash (`cp`/`mv`/`cat >> ~/.claude/memory/x.md`) or an interpreter (`python3 -c`) does NOT carry a `file_path`/`content` this hook can see, so it is not scanned at write time — the same interpreter-bypass class the kit's other @@ -92,8 +92,9 @@ def under_global_corpus(target, base): def pending_text(tool_input): - """Gather every chunk of content this write would introduce: Write.content, - Edit.new_string, and each MultiEdit edit's new_string.""" + """Gather every chunk of content this write would introduce: Write.content and + Edit.new_string. Also scans a batch `edits` list defensively (any future + multi-edit-shaped payload), so a batched write can't slip a marker past the guard.""" chunks = [] if not isinstance(tool_input, dict): return chunks diff --git a/claude/skills/postmortem/SKILL.md b/claude/skills/postmortem/SKILL.md index b37cda5..2b0c78c 100644 --- a/claude/skills/postmortem/SKILL.md +++ b/claude/skills/postmortem/SKILL.md @@ -36,7 +36,7 @@ lessons get banked while they're fresh. Run this at the end of hard work, not "l Memories are project-scoped by default. Promote one to the machine-wide global corpus (`~/.claude/memory/`) ONLY on an explicit decision, and record a one-line **why-global:** (the lesson generalizes past this repo). This rule is advisory; the deterministic gate is the -`pretool-mem-privacy-guard.py` hook, which blocks a **Write/Edit/MultiEdit** into the global +`pretool-mem-privacy-guard.py` hook, which blocks a **Write/Edit** into the global corpus whose content hits a work-marker pattern (`privacy.toml`) — so a leaked marker is stopped at write time. It matches those tools, not Bash/interpreter writes (`cat >>`, `python3 -c`); `mem doctor --privacy` is the backstop that sweeps what slipped through. diff --git a/claude/workflows/design-variants.js b/claude/workflows/design-variants.js index f8b8f2e..5763545 100644 --- a/claude/workflows/design-variants.js +++ b/claude/workflows/design-variants.js @@ -13,16 +13,16 @@ export const meta = { const brief = (typeof args === 'string' && args.trim()) ? args.trim() : null if (!brief) return { error: 'Usage: /design-variants ' } -// German-market mode: adds a compliance judge and puts the german-market checklist -// in every builder's brief. An explicit market keyword turns it on outright. Absent -// that, a SINGLE umlaut is not enough — an English brief naming "Motörhead" or -// "Zürich" must not trip it (CONF26) — so a brief merely WRITTEN in German needs at -// least two independent language signals. When in doubt, say "German market". -const germanKeyword = /german|deutsch|germany|dach\b|\.de\b|impressum|dsgvo/i.test(brief) -const languageSignals = [/[äöüß]/, /„/, /\bGmbH\b/, /\bund\b/i, /\beine[nrms]?\b/i] - .filter(re => re.test(brief)).length -const german = germanKeyword || languageSignals >= 2 -if (german && !germanKeyword) log(`german-market mode enabled by language heuristic (${languageSignals} signals) — add an explicit market keyword to be sure`) +// German-market mode: adds a compliance judge and puts the german-market checklist in +// every builder's brief. The gate is legally load-bearing, so it errs toward ON: an +// explicit market keyword OR a STRONG single signal (GmbH / Impressum / AGB — each +// unambiguously German-market) turns it on outright. WEAK signals (a lone umlaut, „, +// "und"/"eine") could be an English brief naming "Motörhead" or "Zürich" (CONF26), so +// those require two before triggering. When in doubt, say "German market". +const germanKeyword = /german|deutsch|germany|dach\b|\.de\b|impressum|dsgvo|\bGmbH\b|\bAGB\b|\bBFSG\b|\bTTDSG\b|\bTDDDG\b/i.test(brief) +const weakSignals = [/[äöüß]/, /„/, /\bund\b/i, /\beine[nrms]?\b/i].filter(re => re.test(brief)).length +const german = germanKeyword || weakSignals >= 2 +if (german && !germanKeyword) log(`german-market mode enabled by ${weakSignals} weak language signals — name the market explicitly to be sure`) const SKILL_HINT = `If the installed webdesign skill references exist — default location ~/.claude/skills/webdesign/references/, or under $CLAUDE_DIR/skills/webdesign/references/ when that env var is set — read design-views.md${german ? ' AND german-market.md' : ''} from there FIRST and follow them; if absent, proceed from the constraints in this prompt alone.` diff --git a/tests/test_mem_cli.py b/tests/test_mem_cli.py index 9198188..cabdc02 100644 --- a/tests/test_mem_cli.py +++ b/tests/test_mem_cli.py @@ -104,6 +104,17 @@ def test_search_fts5_returns_the_matching_slug(tmp_path): assert "unrelated" not in slugs +def test_fts_search_matches_short_tech_terms(tmp_path): + # A 2-char token like "go"/"ci" must still match in the common fts5 mode. unicode61 + # token-matches (not substrings), so short tokens are safe there — the degraded LIKE + # path filters them only to avoid substring noise. Forcing the two to share the + # len>2 filter silently dropped every short-term query. + write_memory(global_dir(tmp_path), "golang.md", "Go language notes", "go build tips") + run(tmp_path, "index") + hits = json.loads(run(tmp_path, "search", "go", "--json").stdout) + assert any(h["slug"] == "golang" for h in hits), hits + + def test_search_scope_filter_excludes_project_hits(tmp_path): write_memory(global_dir(tmp_path), "g.md", "Kafka consumer lag", "global note about kafka lag") From 9e07520171f50274534c3b85ebce79083ea4627c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:46:22 +0000 Subject: [PATCH 4/5] Harden destructive guard: newline segments, command-subst, rm false-positive Three issues found reviewing the per-segment override rewrite: - Critical: newline is a shell command separator, but `re.sub(r"\s+"," ",cmd)` collapsed it before segmentation, so `FABLE_DESTRUCTIVE_OK=1 git reset --hard\nrm -rf /` became one segment and the override suppressed the rm. Newlines are now preserved (line continuations joined first) and _segments splits on them like `;`. - Critical: a destructive command inside "$(...)" or backticks in double quotes was blanked out of view. Command-substitution contents are now scanned per segment. - Major/correctness: the rm check ran on raw text, so a commit message merely MENTIONING `rm -rf /` false-tripped. It now uses an rm-target view that un-quotes only genuine bare targets (rm -rf "/") while blanking multi-word quoted spans (messages). Also replaced the fragile `why.startswith("recursive rm")` dispatch with an explicit per-pattern flag. New tests cover the newline bypass, command-substitution evasion, and the message false-positive. Suite: 187 passing. --- claude/hooks/pretool-destructive-guard.py | 103 ++++++++++++++++------ tests/test_destructive_guard.py | 19 ++++ 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/claude/hooks/pretool-destructive-guard.py b/claude/hooks/pretool-destructive-guard.py index 6e46dab..d7642c2 100644 --- a/claude/hooks/pretool-destructive-guard.py +++ b/claude/hooks/pretool-destructive-guard.py @@ -35,12 +35,45 @@ OVERRIDE_SEGMENT = re.compile(r"^\s*(?:[A-Za-z_]\w*=\S*\s+)*FABLE_DESTRUCTIVE_OK=1(?:\s|$)") +_QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"") +# Command substitutions execute regardless of the surrounding double quotes, so a +# destructive command hidden in "$(...)" or `...` must stay visible to the checks. +_SUBST = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") + + +def _blank_quotes(s): + """Length-preserving: replace each quoted span with same-length spaces, so a commit + message or echo that merely MENTIONS `reset --hard` can't trip the git checks while + offsets stay aligned with the raw command.""" + return _QUOTED.sub(lambda m: " " * len(m.group()), s) + + +def _rm_view(s): + """Length-preserving view for the rm check: a quoted span holding a BARE target + (`rm -rf "/"`, `rm -rf "$HOME"`) is un-quoted so the target stays matchable, but a + quoted span with whitespace (a commit message that happens to say `rm -rf /`) is + blanked — so the phrase-in-a-message case does not false-trip (only genuine quoted + targets do).""" + def repl(m): + inner = m.group()[1:-1] + if inner and not re.search(r"\s", inner): + return " " + inner + " " # len == len(inner)+2 == len(m.group()) + return " " * len(m.group()) + return _QUOTED.sub(repl, s) + + +def _subst_contents(raw_segment): + """Contents of `$(...)` / backtick command substitutions in a segment (one level).""" + return [a or b for a, b in _SUBST.findall(raw_segment)] + + def _segments(s): - """Yield (start, end) spans of s split on top-level shell separators ; | &. - Operates on the length-preserving quote-stripped view, so separators that were - inside quotes are already spaces and cannot cut a segment.""" + """Yield (start, end) spans of s split on top-level shell separators ; | & and bare + newlines. Operates on the length-preserving quote-stripped view, so separators inside + quotes are already spaces and cannot cut a segment. A newline is a command separator + exactly like `;` — collapsing it would let an override on one line suppress the next.""" spans, start = [], 0 - for m in re.finditer(r"[;|&]+", s): + for m in re.finditer(r"[;|&\n]+", s): spans.append((start, m.start())) start = m.end() spans.append((start, len(s))) @@ -59,15 +92,18 @@ def _segments(s): "git switch -f/--discard-changes overwrites uncommitted local modifications"), ] RESTORE = re.compile(r"\bgit\b[^|;&]*\brestore\b([^|;&]*)") +# (pattern, why, raw_targets): raw_targets=True means match against the rm-target view +# (bare quoted targets preserved) instead of the fully quote-stripped view — an explicit +# flag, not a fragile substring check on `why`. ALWAYS_DANGEROUS = [ (re.compile(r"\bgit\b[^|;&]*\bstash\s+(?:drop|clear)\b"), - "git stash drop/clear permanently discards stashed work"), + "git stash drop/clear permanently discards stashed work", False), # rm with a recursive flag (-r/-R, combined or separate; -f irrelevant — rm -r # deletes without prompting in non-interactive shells) aimed at a catastrophic # first target: / /* ~ ~/ $HOME . ./ .. ../ * (re.compile(r"\brm\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*[rR][a-zA-Z]*(?:\s+-\S+)*" r"\s+(?:\"|')?(?:/(?:\*)?|~(?:/)?|\$HOME(?:/)?|\.\.?(?:/)?|\*)(?:\"|')?(?:\s|$|;)"), - "recursive rm aimed at /, ~, ., .. or * is unrecoverable"), + "recursive rm aimed at /, ~, ., .. or * is unrecoverable", True), ] # --force / -f, plus the refspec spelling of force (`git push origin +main`) — # the leading + IS --force for that ref and evades a flag-only check. @@ -105,43 +141,52 @@ def main(): cmd = tool_input.get("command", "") if isinstance(tool_input, dict) else "" if not isinstance(cmd, str) or not cmd.strip(): return 0 - flat = re.sub(r"\s+", " ", cmd) - # Length-PRESERVING quote strip (each quoted span -> same-length spaces) so a commit - # message or echo that merely MENTIONS "reset --hard" never trips the git patterns, - # while offsets in `unquoted` still line up 1:1 with `flat` — the rm pattern needs the - # raw (quoted) targets, so it is checked against the matching slice of `flat`. - unquoted = re.sub(r"'[^']*'|\"[^\"]*\"", lambda m: " " * len(m.group()), flat) + # Honor line continuations (join `\`), then collapse only HORIZONTAL + # whitespace so real newlines survive as command separators (a newline separates + # commands exactly like `;`; collapsing it let an override on one line suppress the + # next — see _segments). + cmd = re.sub(r"\\\r?\n", " ", cmd).replace("\r\n", "\n").replace("\r", "\n") + flat = re.sub(r"[^\S\n]+", " ", cmd).strip() + # Length-aligned views: `blanked` quote-strips (so a MENTION of `reset --hard` in a + # message can't trip), `rm_view` keeps genuine quoted rm targets, both 1:1 with flat. + blanked = _blank_quotes(flat) + rm_view = _rm_view(flat) # Evaluate EVERY check per shell segment. The override is a shell env-assignment - # prefix: it applies ONLY to the command it prefixes, so it must exempt only its own + # prefix: it applies ONLY to the command it prefixes, so it exempts only its own # segment — `FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /` still blocks the rm. - # Splitting the length-aligned `unquoted` also keeps a separator inside a quote (now - # spaces) from wrongly cutting a segment. + # Command substitutions ("$(...)"/backticks) execute regardless of quoting, so their + # contents are scanned too — a destructive command can't hide inside one. tree_reason = None - for seg in _segments(unquoted): - useg = unquoted[seg[0]:seg[1]] - rseg = flat[seg[0]:seg[1]] + for s, e in _segments(blanked): + useg = blanked[s:e] + rmseg = rm_view[s:e] if OVERRIDE_SEGMENT.search(useg): continue # user explicitly approved THIS command; leave the rest guarded - for pat, why in ALWAYS_DANGEROUS: - if pat.search(rseg if why.startswith("recursive rm") else useg): + subs = _subst_contents(flat[s:e]) + git_probes = [useg] + subs # git/tree/force patterns + for pat, why, raw_targets in ALWAYS_DANGEROUS: + hays = ([rmseg] + subs) if raw_targets else git_probes + if any(pat.search(h) for h in hays): return block(why) - if FORCE_PUSH.search(useg) and not FORCE_WITH_LEASE.search(useg): + if any(FORCE_PUSH.search(h) and not FORCE_WITH_LEASE.search(h) for h in git_probes): return block("bare force-push can destroy remote history; use --force-with-lease, " "and only with user approval") if tree_reason is None: for pat, why in TREE_DESTROYERS: - if pat.search(useg): + if any(pat.search(h) for h in git_probes): tree_reason = why break if tree_reason is None: - m = RESTORE.search(useg) - if m: - args = m.group(1) - # `git restore --staged ` only unstages — safe. Anything that - # touches the worktree discards local edits. - if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): - tree_reason = "git restore discards uncommitted modifications to the given paths" + for h in git_probes: + m = RESTORE.search(h) + if m: + args = m.group(1) + # `git restore --staged ` only unstages — safe. Anything that + # touches the worktree discards local edits. + if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): + tree_reason = "git restore discards uncommitted modifications to the given paths" + break if tree_reason: n = dirty_paths(data.get("cwd")) if n: diff --git a/tests/test_destructive_guard.py b/tests/test_destructive_guard.py index 7766b85..4584140 100644 --- a/tests/test_destructive_guard.py +++ b/tests/test_destructive_guard.py @@ -162,10 +162,29 @@ def test_override_is_scoped_to_its_own_segment(tmp_path): "FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /", cwd=repo).returncode == 2 assert run_hook( "FABLE_DESTRUCTIVE_OK=1 git reset --hard && git clean -fd", cwd=repo).returncode == 2 + # A bare NEWLINE is a command separator too — the override must not leak across it. + assert run_hook( + "FABLE_DESTRUCTIVE_OK=1 git reset --hard\nrm -rf /", cwd=repo).returncode == 2 # ...but the approved segment alone still passes. assert run_hook("FABLE_DESTRUCTIVE_OK=1 git reset --hard", cwd=repo).returncode == 0 +def test_command_substitution_in_double_quotes_is_inspected(tmp_path): + # A destructive command hidden in "$(...)" or `...` still executes — the guard must + # see through the surrounding double quotes. + repo = make_repo(tmp_path, dirty=True) + assert run_hook('echo "$(git reset --hard)"', cwd=repo).returncode == 2 + assert run_hook("echo `git clean -fd`", cwd=repo).returncode == 2 + + +def test_rm_phrase_in_commit_message_does_not_false_trip(tmp_path): + # A commit message that merely MENTIONS `rm -rf /` is not an rm command; only a + # genuine (even quoted) target should block. + repo = make_repo(tmp_path, dirty=True) + assert run_hook('git commit -m "note: never run rm -rf / here"', cwd=repo).returncode == 0 + assert run_hook('rm -rf "/"', cwd=repo).returncode == 2 + + def test_non_git_cwd_fails_open(tmp_path): assert run_hook("git reset --hard", cwd=tmp_path).returncode == 0 From d79c529d1d5d97d12d0d49c7d22f195375c80246 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:58:49 +0000 Subject: [PATCH 5/5] Guard: make substitution scanning coherent (single-quote, inner-separator, ${HOME}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the command-substitution scanning added last commit — CodeRabbit found it half-done: - Single-quoted '$(...)' is a literal, not a command — the naive scan false-blocked it. Substitutions are now extracted only from the single-quote-blanked view (where they are actually active). - A separator inside a substitution ($(true; rm -rf /)) split it out of the scan. Active substitution spans are now blanked before top-level segmentation, and their contents are scanned recursively (one nesting level) as their own commands. - rm -rf "${HOME}" (braced expansion) is the same catastrophic target as $HOME; the rm pattern now matches both. Refactored the per-command checks behind _iter_command_slices() (segments + active substitution contents, override-scoped, bounded depth). Residual regex-tripwire limits (nested/process substitution, sh -c/eval) documented in README known-limits. New tests for all three cases. Suite: 189 passing. --- README.md | 2 +- claude/hooks/pretool-destructive-guard.py | 94 ++++++++++++++--------- tests/test_destructive_guard.py | 16 ++++ 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c7aacf7..ea0fee6 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ Going the other direction — running the kit on a **smaller** driver model (a S - `CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000` is best-effort: harmless (clamped per model), but whether it raises the effective cap is **unverified** — the kit's own doctrine requires saying so. - The loop-alarm hook treats a failure as the `PostToolUseFailure` event (Claude Code 2.1.x routes failing Bash commands there, not to `PostToolUse`), and still honours an explicit exit code inside a legacy `PostToolUse` `tool_response`. If a future CLI renames or drops that failure event, the alarm goes silently inert (fail-open by design) — verify once with a deliberately failing command repeated 3×, and see the CLI-version note in the hook's docstring. - The test-weakening alarm reads Edit/Write payloads, so a skip marker smuggled in via a Bash heredoc doesn't trip it at edit time — but the claim-audit gate now flags any file-writing Bash command that names a test path, so the stop-time audit still fires. -- The destructive-command guard is a tripwire, not a jail: known bypass classes include commands wrapped in `sh -c '...'`, destructive flags hidden by quoting (`git reset '--hard'`), and some `rm -rf` variants (`~/*`, `./*`). The claim-audit gate similarly misses file writes done through interpreters (`python3 -c`) and some multi-line Bash forms. These hooks raise the cost of the documented *reflexive* failure modes; they do not stop a determined evader — pair them with the doctrine, and treat any deliberate bypass in a transcript as the incident. +- The destructive-command guard is a tripwire, not a jail. It is segment-aware (evaluates each `;`/`|`/`&&`/newline-separated command on its own, so an override or `--force-with-lease` in one segment can't excuse another) and scans one level of command substitution (`"$(…)"`, backticks), but it is a regex over the command string, not a shell parser: known residual bypass classes include commands wrapped in `sh -c '...'`/`eval`, destructive flags assembled from variables, nested (`$($(…))`) or process (`<(…)`) substitution, and some `rm -rf` glob variants. The claim-audit gate similarly misses file writes done through interpreters (`python3 -c`) and some multi-line Bash forms. These hooks raise the cost of the documented *reflexive* failure modes; they do not stop a determined evader — pair them with the doctrine, and treat any deliberate bypass in a transcript as the incident. - The fable-mem session journal and its reindex run on SessionEnd, which fires on graceful exit (`/clear`, resume, logout, quit) but is **not** guaranteed on a hard crash or SIGKILL — a session killed mid-flight leaves no breadcrumb, and its memory waits for the next SessionEnd to be indexed. The corpus files are never at risk (the model writes them during the session); only the journal line and index freshness are. - The privacy guard's `privacy.toml` patterns are **necessary, not sufficient**: they block the markers you list, not the ones you forgot. The list ships empty and conservative so a fresh install never false-positives — which means it catches nothing until you fill in your real work markers. Treat it as a tripwire for known-shaped leaks, not a classifier, and run `mem doctor --privacy` before promoting. The guard is also **tool-scoped**: it fires on `Write|Edit` into the corpus, not on Bash/interpreter writes (`cp`/`mv`/`cat >>`/`python3 -c`) — the same interpreter-bypass class the destructive-guard and claim-audit gates document — so a promotion done by copying rather than re-writing lands unscanned; `mem doctor --privacy` (which now sweeps the `.ndjson` journal too, not just `*.md`) is the backstop. - fable-mem claims `~/.claude/memory/` because no native feature uses it: main-session auto-memory is per-repo (`~/.claude/projects/

/memory/`) and native "user scope" memory is **per-subagent islands** (`~/.claude/agent-memory//`), not a shared cross-project store. If a future Claude Code ships a real shared user-memory surface at that path, re-check for collision before upgrading. diff --git a/claude/hooks/pretool-destructive-guard.py b/claude/hooks/pretool-destructive-guard.py index d7642c2..3f034a9 100644 --- a/claude/hooks/pretool-destructive-guard.py +++ b/claude/hooks/pretool-destructive-guard.py @@ -36,16 +36,28 @@ _QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"") -# Command substitutions execute regardless of the surrounding double quotes, so a -# destructive command hidden in "$(...)" or `...` must stay visible to the checks. +_SINGLE = re.compile(r"'[^']*'") +# Command substitutions execute inside double quotes and unquoted, but NOT inside single +# quotes (there they are literal). A destructive command hidden in "$(...)" or `...` must +# stay visible to the checks; one in '$(...)' must not false-trip. Single level of nesting. _SUBST = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") +def _blank(s): + return " " * len(s) + + def _blank_quotes(s): """Length-preserving: replace each quoted span with same-length spaces, so a commit message or echo that merely MENTIONS `reset --hard` can't trip the git checks while offsets stay aligned with the raw command.""" - return _QUOTED.sub(lambda m: " " * len(m.group()), s) + return _QUOTED.sub(lambda m: _blank(m.group()), s) + + +def _blank_single_quotes(s): + """Length-preserving blanking of SINGLE-quoted spans only — the view in which command + substitutions are actually active (single quotes suppress them).""" + return _SINGLE.sub(lambda m: _blank(m.group()), s) def _rm_view(s): @@ -62,9 +74,10 @@ def repl(m): return _QUOTED.sub(repl, s) -def _subst_contents(raw_segment): - """Contents of `$(...)` / backtick command substitutions in a segment (one level).""" - return [a or b for a, b in _SUBST.findall(raw_segment)] +def _subst_contents(segment): + """Contents of ACTIVE `$(...)` / backtick command substitutions in a segment (one + nesting level). Pass a single-quote-blanked slice so literal '$(...)' is ignored.""" + return [a or b for a, b in _SUBST.findall(segment)] def _segments(s): @@ -102,7 +115,7 @@ def _segments(s): # deletes without prompting in non-interactive shells) aimed at a catastrophic # first target: / /* ~ ~/ $HOME . ./ .. ../ * (re.compile(r"\brm\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*[rR][a-zA-Z]*(?:\s+-\S+)*" - r"\s+(?:\"|')?(?:/(?:\*)?|~(?:/)?|\$HOME(?:/)?|\.\.?(?:/)?|\*)(?:\"|')?(?:\s|$|;)"), + r"\s+(?:\"|')?(?:/(?:\*)?|~(?:/)?|\$\{?HOME\}?(?:/)?|\.\.?(?:/)?|\*)(?:\"|')?(?:\s|$|;)"), "recursive rm aimed at /, ~, ., .. or * is unrecoverable", True), ] # --force / -f, plus the refspec spelling of force (`git push origin +main`) — @@ -133,6 +146,27 @@ def block(reason): return 2 +def _iter_command_slices(text, depth=0): + """Yield (useg, rmseg) for each command in `text`: top-level segments split on shell + separators, and — one nesting level deep — the commands inside each ACTIVE command + substitution (`"$(...)"` / backticks / unquoted `$(...)`; single-quoted ones are + literal and skipped). `useg` is the quote-stripped view (for git/force/tree patterns), + `rmseg` the rm-target view (genuine quoted targets kept). An override-approved + top-level segment — and everything inside it — is skipped. Recursion is bounded.""" + qb = _blank_quotes(text) + rmv = _rm_view(text) + sq = _blank_single_quotes(text) # view where substitutions are ACTIVE + seg_view = _SUBST.sub(lambda m: _blank(m.group()), qb) # blank substs so inner ; don't split + for s, e in _segments(seg_view): + useg = seg_view[s:e] + if OVERRIDE_SEGMENT.search(useg): + continue # user explicitly approved THIS command + yield useg, rmv[s:e] + if depth < 3: + for content in _subst_contents(sq[s:e]): + yield from _iter_command_slices(content, depth + 1) + + def main(): data = json.load(sys.stdin) if data.get("tool_name") != "Bash": @@ -147,46 +181,32 @@ def main(): # next — see _segments). cmd = re.sub(r"\\\r?\n", " ", cmd).replace("\r\n", "\n").replace("\r", "\n") flat = re.sub(r"[^\S\n]+", " ", cmd).strip() - # Length-aligned views: `blanked` quote-strips (so a MENTION of `reset --hard` in a - # message can't trip), `rm_view` keeps genuine quoted rm targets, both 1:1 with flat. - blanked = _blank_quotes(flat) - rm_view = _rm_view(flat) - - # Evaluate EVERY check per shell segment. The override is a shell env-assignment - # prefix: it applies ONLY to the command it prefixes, so it exempts only its own - # segment — `FABLE_DESTRUCTIVE_OK=1 git reset --hard; rm -rf /` still blocks the rm. - # Command substitutions ("$(...)"/backticks) execute regardless of quoting, so their - # contents are scanned too — a destructive command can't hide inside one. + + # The override is a shell env-assignment prefix: it applies ONLY to the command it + # prefixes, so it exempts only its own segment. Unconditional blocks (rm at a + # catastrophic target, stash drop, force-push) fire immediately; tree-destroyers block + # only when the working tree is dirty, so they are collected and checked once at the end. tree_reason = None - for s, e in _segments(blanked): - useg = blanked[s:e] - rmseg = rm_view[s:e] - if OVERRIDE_SEGMENT.search(useg): - continue # user explicitly approved THIS command; leave the rest guarded - subs = _subst_contents(flat[s:e]) - git_probes = [useg] + subs # git/tree/force patterns + for useg, rmseg in _iter_command_slices(flat): for pat, why, raw_targets in ALWAYS_DANGEROUS: - hays = ([rmseg] + subs) if raw_targets else git_probes - if any(pat.search(h) for h in hays): + if pat.search(rmseg if raw_targets else useg): return block(why) - if any(FORCE_PUSH.search(h) and not FORCE_WITH_LEASE.search(h) for h in git_probes): + if FORCE_PUSH.search(useg) and not FORCE_WITH_LEASE.search(useg): return block("bare force-push can destroy remote history; use --force-with-lease, " "and only with user approval") if tree_reason is None: for pat, why in TREE_DESTROYERS: - if any(pat.search(h) for h in git_probes): + if pat.search(useg): tree_reason = why break if tree_reason is None: - for h in git_probes: - m = RESTORE.search(h) - if m: - args = m.group(1) - # `git restore --staged ` only unstages — safe. Anything that - # touches the worktree discards local edits. - if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): - tree_reason = "git restore discards uncommitted modifications to the given paths" - break + m = RESTORE.search(useg) + if m: + args = m.group(1) + # `git restore --staged ` only unstages — safe. Anything that + # touches the worktree discards local edits. + if "--staged" not in args or "--worktree" in args or re.search(r"\s-W\b", args): + tree_reason = "git restore discards uncommitted modifications to the given paths" if tree_reason: n = dirty_paths(data.get("cwd")) if n: diff --git a/tests/test_destructive_guard.py b/tests/test_destructive_guard.py index 4584140..c5b27f4 100644 --- a/tests/test_destructive_guard.py +++ b/tests/test_destructive_guard.py @@ -175,6 +175,22 @@ def test_command_substitution_in_double_quotes_is_inspected(tmp_path): repo = make_repo(tmp_path, dirty=True) assert run_hook('echo "$(git reset --hard)"', cwd=repo).returncode == 2 assert run_hook("echo `git clean -fd`", cwd=repo).returncode == 2 + # A separator INSIDE the substitution must not split it away from the scan. + assert run_hook("echo $(true; rm -rf /)", cwd=repo).returncode == 2 + + +def test_single_quoted_substitution_is_literal_not_executed(tmp_path): + # Single quotes suppress command substitution, so '$(...)' is a literal string and + # must NOT be treated as a hidden command (no false block). + repo = make_repo(tmp_path, dirty=True) + assert run_hook("echo '$(git reset --hard)'", cwd=repo).returncode == 0 + + +def test_braced_home_expansion_is_a_catastrophic_rm_target(tmp_path): + # rm -rf "${HOME}" expands to the home dir exactly like $HOME — both must block. + repo = make_repo(tmp_path, dirty=True) + assert run_hook('rm -rf "${HOME}"', cwd=repo).returncode == 2 + assert run_hook("rm -rf ${HOME}", cwd=repo).returncode == 2 def test_rm_phrase_in_commit_message_does_not_false_trip(tmp_path):