From 1f3be3bdae62d5bcbee0f909f921e07f03b21d44 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:35:24 +0300 Subject: [PATCH 01/13] Harden test-after-edit and clean up install hygiene - test-after-edit: FABLE_TEST_HOOK_ALLOW trust allowlist and a per-project .fable-test override so the hook only auto-runs commands in repos you trust (documented in SECURITY.md, covered by new tests) - merge_settings: write the pristine settings.json.bak only when absent so re-installs can't destroy the original restore point - close leaked file handles in merge_settings, uninstall and the test hook - launcher defaults to --effort xhigh; ultracode becomes an explicit opt-in --- CHANGELOG.md | 17 +++++ README.md | 4 +- SECURITY.md | 18 ++++- hooks/test-after-edit.py | 120 ++++++++++++++++++++++++++-------- scripts/merge_settings.py | 16 ++++- shell/fable.ps1 | 13 ++-- shell/fable.zsh | 13 ++-- tests/test_test_after_edit.py | 43 ++++++++++++ uninstall.py | 6 +- 9 files changed, 202 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2347a..07e9edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- `merge_settings.py` overwrote `settings.json.bak` on every install run, so a + second install destroyed the pristine pre-install backup (contradicting the + "safe to re-run" promise). It now writes that backup only when absent, keeping + the original restore point intact. +- Closed leaked file handles (`json.load(open(...))` / `json.dump(..., open(...))`) + in `merge_settings.py`, `uninstall.py`, and `test-after-edit.py` — they now use + `with` blocks, which also avoids file-lock surprises on Windows. - `fable-trigger.py` read the playbook from a hardcoded `/Users/ak/...` path, so on-demand injection silently failed for everyone but the original author. It now resolves `~/.claude/FABLE_PLAYBOOK.md`. @@ -16,6 +23,11 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `.lockb` skip entry. ### Added +- `test-after-edit.py` gained a `FABLE_TEST_HOOK_ALLOW` allowlist (os.pathsep- + separated trusted root prefixes) so the auto-run test hook can be confined to + repositories you trust, plus a per-project `.fable-test` file to pin the exact + command it runs (e.g. a fast, scoped command instead of the whole suite in a + monorepo). Documented in `SECURITY.md`; covered by new tests. - **One-command, cross-platform installer** (`install.py`) for Windows, macOS, and Linux. `install.sh` / `install.ps1` are thin wrappers that exec it. - PowerShell launcher (`shell/fable.ps1`) alongside the zsh one. @@ -28,6 +40,11 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `SECURITY.md`, `CONTRIBUTING.md`, and this changelog. ### Changed +- The `fable` launcher now defaults to `--effort xhigh` instead of + `--settings '{"ultracode": true}'`. xhigh still trips the playbook trigger and + drives heavy reasoning, without ultracode's token-hungry multi-agent + auto-orchestration; ultracode is now an explicit opt-in (documented in the + launcher and README). - `merge_settings.py` writes the absolute interpreter (`sys.executable`) and absolute hook paths into `settings.json`, so the hooks fire without `$HOME` or `python3` resolution at hook-run time. diff --git a/README.md b/README.md index 0fb9e16..51f6cab 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ python install.py # Windows (use python3 on macOS / Linux) Then reload your shell and launch: ```sh -fable # Opus 4.8 + Fable prompt + ultracode +fable # Opus 4.8 + Fable prompt + xhigh effort ``` Prefer a native one-liner? `./install.sh` (macOS / Linux) and `.\install.ps1` (Windows) just locate Python and run `install.py` for you. @@ -65,7 +65,7 @@ Removes the bundled files from `~/.claude`, strips the `fable` launcher line, an - **Hooks** — `fable-trigger.py` injects the playbook at `xhigh`/`max`/`ultracode`; `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. - **`/ground` skill + `grounding-verifier` agent** — a self-terminating grounding loop and a cold verifier that assumes every claim is wrong until the live code proves it. - **Skills** — `claude-design-patterns` (web-UI engineering), `webapp-testing`, `mcp-builder`, `skill-creator`, `explore-data`. -- **`fable` launcher** — Opus 4.8 + the prompt + `ultracode` effort (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). +- **`fable` launcher** — Opus 4.8 + the prompt + `xhigh` effort (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Swap `--effort xhigh` for `--settings '{"ultracode": true}'` in the launcher. ## The honest ceiling diff --git a/SECURITY.md b/SECURITY.md index 9f828da..0be4c80 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,12 +24,28 @@ test command in the edited project's directory. Because the test hook runs a project's test command, **only enable fable-mode in repositories you trust** — the same caution you would apply to running their -tests yourself. +tests yourself. The command is derived from repo content (`package.json` scripts, +a `Makefile` `test:` target, or a `.fable-test` file), so a hostile repository +could point it at an arbitrary command; treat it exactly like running that repo's +build yourself. + +### Restricting where it runs (the trust gate) + +Set `FABLE_TEST_HOOK_ALLOW` to an `os.pathsep`-separated list of directory +prefixes (`:` on Unix, `;` on Windows). When it is set, the hook auto-runs **only** +for projects at or under one of those roots and stays silent everywhere else — so +editing a freshly-cloned, untrusted repo triggers nothing. When it is unset the +default is zero-config (runs in any project), which is why the trust caution above +applies. ### Turning the test hook off - Set `FABLE_NO_TEST_HOOK=1` to disable it entirely. +- Set `FABLE_TEST_HOOK_ALLOW=` to restrict it to roots you trust. - Tune `FABLE_TEST_HOOK_DEBOUNCE` / `FABLE_TEST_HOOK_TIMEOUT` (seconds). +- Drop a `.fable-test` file in a project root to pin the exact command it runs + (e.g. a fast, scoped subset instead of the whole suite). Note this file is repo + content, so it is governed by the same `FABLE_TEST_HOOK_ALLOW` gate. - Or remove the `PostToolUse` entry from `~/.claude/settings.json` — `uninstall.py` does this for you. diff --git a/hooks/test-after-edit.py b/hooks/test-after-edit.py index bae668a..d18d669 100644 --- a/hooks/test-after-edit.py +++ b/hooks/test-after-edit.py @@ -13,6 +13,16 @@ FABLE_NO_TEST_HOOK=1 disable entirely FABLE_TEST_HOOK_DEBOUNCE=45 min seconds between runs per project root FABLE_TEST_HOOK_TIMEOUT=90 per-run timeout in seconds + FABLE_TEST_HOOK_ALLOW= os.pathsep-separated trusted root prefixes; when + set, only projects under one of them auto-run. The + opt-in hardening lever for the fact that this hook + executes a repo-defined command (see SECURITY.md). + +Per-project knob (file): + .fable-test in the project root — its first non-comment line is + the exact command to run instead of the auto-detected + one. Lets a monorepo pin a fast, scoped command so + the hook doesn't run the whole suite on every edit. """ import sys import os @@ -46,15 +56,60 @@ def emit(context): sys.exit(0) +def read_fable_test(d): + """If d/.fable-test exists, return its first non-comment, non-blank line (the + exact command to run); else None.""" + path = os.path.join(d, ".fable-test") + if not os.path.isfile(path): + return None + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + return line + except Exception: + return None + return None + + +def root_allowed(root): + """Honour the FABLE_TEST_HOOK_ALLOW allowlist. Unset -> allow all (zero-config + default). Set -> only run when `root` is at/under one of the listed prefixes. + This is the trust gate for auto-executing a repo-defined test command.""" + allow = os.environ.get("FABLE_TEST_HOOK_ALLOW", "").strip() + if not allow: + return True + root_abs = os.path.abspath(root) + for prefix in allow.split(os.pathsep): + prefix = prefix.strip() + if not prefix: + continue + prefix_abs = os.path.abspath(os.path.expanduser(prefix)) + try: + if os.path.commonpath([root_abs, prefix_abs]) == prefix_abs: + return True + except ValueError: + continue # e.g. different drives on Windows — not under this prefix + return False + + def find_root_and_cmd(start_dir): - """Walk up from start_dir; return (root, command_list, label) or (None, None, None).""" + """Walk up from start_dir; return (root, cmd, label, is_shell) or + (None, None, None, False). is_shell=True means cmd is a raw shell string (from + a .fable-test override); otherwise cmd is an argv list.""" d = os.path.abspath(start_dir) while True: + # Per-project override wins over auto-detection. + custom = read_fable_test(d) + if custom: + return d, custom, custom, True # Node / JS-TS pkg = os.path.join(d, "package.json") if os.path.isfile(pkg): try: - scripts = json.load(open(pkg)).get("scripts", {}) + with open(pkg) as f: + scripts = json.load(f).get("scripts", {}) except Exception: scripts = {} test = scripts.get("test", "") @@ -67,31 +122,33 @@ def find_root_and_cmd(start_dir): pm = "bun" else: pm = "npm" - return d, [pm, "test"], f"{pm} test" + return d, [pm, "test"], f"{pm} test", False # Python if any(os.path.isfile(os.path.join(d, f)) for f in ("pyproject.toml", "setup.cfg", "pytest.ini", "tox.ini")) \ or os.path.isdir(os.path.join(d, "tests")): if os.path.isfile(os.path.join(d, "uv.lock")): - return d, ["uv", "run", "pytest", "-q"], "uv run pytest -q" - return d, [sys.executable, "-m", "pytest", "-q"], "pytest -q" + return d, ["uv", "run", "pytest", "-q"], "uv run pytest -q", False + return d, [sys.executable, "-m", "pytest", "-q"], "pytest -q", False # Rust if os.path.isfile(os.path.join(d, "Cargo.toml")): - return d, ["cargo", "test", "-q"], "cargo test -q" + return d, ["cargo", "test", "-q"], "cargo test -q", False # Go if os.path.isfile(os.path.join(d, "go.mod")): - return d, ["go", "test", "./..."], "go test ./..." + return d, ["go", "test", "./..."], "go test ./...", False # Make mk = os.path.join(d, "Makefile") if os.path.isfile(mk): try: - if any(line.startswith("test:") for line in open(mk)): - return d, ["make", "test"], "make test" + with open(mk) as f: + has_test = any(line.startswith("test:") for line in f) except Exception: - pass + has_test = False + if has_test: + return d, ["make", "test"], "make test", False parent = os.path.dirname(d) if parent == d: - return None, None, None + return None, None, None, False d = parent @@ -128,31 +185,38 @@ def main(): return start = os.path.dirname(fpath) or data.get("cwd") or os.getcwd() - root, cmd, label = find_root_and_cmd(start) + root, cmd, label, is_shell = find_root_and_cmd(start) if not cmd: return # no test command in this project — stay silent + if not root_allowed(root): + return # outside the FABLE_TEST_HOOK_ALLOW allowlist — stay silent if debounced(root): return - # On Windows the interpreter path may contain spaces and several runners - # (npm/pnpm/yarn/make) are .cmd shims that can't be exec'd directly — use the - # bare interpreter name and let cmd.exe resolve it via PATHEXT under shell=True. - prog = cmd[0] - if IS_WINDOWS and prog == sys.executable: - prog = "python" - if not shutil.which(prog): - return # runner not installed — silent - run_args = [prog] + cmd[1:] - - t0 = time.time() - try: + if is_shell: + # Custom .fable-test command — run it through the shell on every platform. + invocation, use_shell = cmd, True + else: + # On Windows the interpreter path may contain spaces and several runners + # (npm/pnpm/yarn/make) are .cmd shims that can't be exec'd directly — use + # the bare interpreter name and let cmd.exe resolve it via PATHEXT under + # shell=True. + prog = cmd[0] + if IS_WINDOWS and prog == sys.executable: + prog = "python" + if not shutil.which(prog): + return # runner not installed — silent + run_args = [prog] + cmd[1:] if IS_WINDOWS: # tokens are bare (no spaces), so a plain join is unambiguous for cmd.exe - p = subprocess.run(" ".join(run_args), cwd=root, capture_output=True, - text=True, timeout=TIMEOUT, shell=True) + invocation, use_shell = " ".join(run_args), True else: - p = subprocess.run(run_args, cwd=root, capture_output=True, text=True, - timeout=TIMEOUT) + invocation, use_shell = run_args, False + + t0 = time.time() + try: + p = subprocess.run(invocation, cwd=root, capture_output=True, text=True, + timeout=TIMEOUT, shell=use_shell) except subprocess.TimeoutExpired: emit(f"test-after-edit ⏱ — `{label}` exceeded {TIMEOUT}s in {root}; " "result inconclusive, run it manually before claiming done.") diff --git a/scripts/merge_settings.py b/scripts/merge_settings.py index 793d976..f98e604 100644 --- a/scripts/merge_settings.py +++ b/scripts/merge_settings.py @@ -16,9 +16,18 @@ def merge(settings_path, py, hooks_dir): - d = json.load(open(settings_path)) if os.path.exists(settings_path) else {} if os.path.exists(settings_path): - shutil.copy(settings_path, settings_path + ".bak") + with open(settings_path) as f: + d = json.load(f) + # Preserve the pristine pre-install settings exactly once. A naive + # copy-every-run would, on the second install, overwrite this backup with + # already-merged content — destroying the only good restore point. Create + # it only when absent; re-runs are idempotent and non-destructive anyway. + backup = settings_path + ".bak" + if not os.path.exists(backup): + shutil.copy(settings_path, backup) + else: + d = {} d["alwaysThinkingEnabled"] = True hooks = d.setdefault("hooks", {}) @@ -40,7 +49,8 @@ def ensure(event, entry, needle): "hooks": [{"type": "command", "command": cmd("test-after-edit.py")}]}, "test-after-edit.py") - json.dump(d, open(settings_path, "w"), indent=2) + with open(settings_path, "w") as f: + json.dump(d, f, indent=2) print(" settings.json updated") diff --git a/shell/fable.ps1 b/shell/fable.ps1 index e61a546..cb4fca7 100644 --- a/shell/fable.ps1 +++ b/shell/fable.ps1 @@ -2,13 +2,14 @@ # . C:\path\to\fable-mode\shell\fable.ps1 # # Launches Claude Code (Opus 4.8) with the Fable 5 system prompt appended and -# ultracode effort (sends xhigh to the model AND auto-orchestrates multi-agent -# workflows for substantive tasks — the heaviest mode). ultracode is session-only, -# so it's set via --settings, not --effort. It also trips fable-trigger.py, which -# layers FABLE_PLAYBOOK execution discipline on top. +# xhigh effort — the heavy-reasoning lever that closes part of the measured +# 70-vs-47 reasoning-density gap prose alone can't. It also trips fable-trigger.py, +# which layers FABLE_PLAYBOOK execution discipline on top. # # install.ps1 copies fable-system.md into ~\.claude for you. -# If ultracode's auto-workflows burn too many tokens, swap --settings for --effort xhigh. +# Want multi-agent auto-orchestration too? Swap `--effort xhigh` for +# `--settings '{"ultracode": true}'` — that sends xhigh AND auto-runs workflows for +# substantive tasks (the heaviest mode, and heavier on tokens). function fable { - claude --append-system-prompt-file "$HOME\.claude\fable-system.md" --settings '{"ultracode": true}' @args + claude --append-system-prompt-file "$HOME\.claude\fable-system.md" --effort xhigh @args } diff --git a/shell/fable.zsh b/shell/fable.zsh index 7598d98..f61416a 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -1,13 +1,14 @@ # Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` # # Launches Claude Code (Opus 4.8) with the Fable 5 system prompt appended and -# ultracode effort (sends xhigh to the model AND auto-orchestrates multi-agent -# workflows for substantive tasks — the heaviest mode). ultracode is session-only, -# so it's set via --settings, not --effort. It also trips fable-trigger.py, which -# layers FABLE_PLAYBOOK execution discipline on top. +# xhigh effort — the heavy-reasoning lever that closes part of the measured +# 70-vs-47 reasoning-density gap prose alone can't. It also trips fable-trigger.py, +# which layers FABLE_PLAYBOOK execution discipline on top. # # install.sh copies fable-system.md into ~/.claude for you. -# If ultracode's auto-workflows burn too many tokens, swap --settings for --effort xhigh. +# Want multi-agent auto-orchestration too? Swap `--effort xhigh` for +# `--settings '{"ultracode": true}'` — that sends xhigh AND auto-runs workflows for +# substantive tasks (the heaviest mode, and heavier on tokens). fable() { - claude --append-system-prompt-file "$HOME/.claude/fable-system.md" --settings '{"ultracode": true}' "$@" + claude --append-system-prompt-file "$HOME/.claude/fable-system.md" --effort xhigh "$@" } diff --git a/tests/test_test_after_edit.py b/tests/test_test_after_edit.py index bdb28de..5f35cf0 100644 --- a/tests/test_test_after_edit.py +++ b/tests/test_test_after_edit.py @@ -70,3 +70,46 @@ def test_disabled_via_env(tmp_path): def test_non_edit_tool_ignored(tmp_path): src = make_py_project(tmp_path, passing=True) assert run({"file_path": str(src)}, tool_name="Read") == "" + + +def _exit_cmd(code): + # Quoted so a sys.executable path with spaces still parses under the shell. + return '"{}" -c "import sys; sys.exit({})"'.format(sys.executable, code) + + +def test_fable_test_override_passing(tmp_path): + (tmp_path / ".fable-test").write_text(_exit_cmd(0) + "\n", encoding="utf-8") + src = tmp_path / "mod.py" + src.write_text("x = 1\n", encoding="utf-8") + out = run({"file_path": str(src)}) + assert "passed" in out, out + + +def test_fable_test_override_failing(tmp_path): + (tmp_path / ".fable-test").write_text(_exit_cmd(1) + "\n", encoding="utf-8") + src = tmp_path / "mod.py" + src.write_text("x = 1\n", encoding="utf-8") + out = run({"file_path": str(src)}) + assert "FAILED" in out, out + + +def test_fable_test_override_skips_comments_and_blanks(tmp_path): + (tmp_path / ".fable-test").write_text( + "# a comment\n\n" + _exit_cmd(0) + "\n", encoding="utf-8") + src = tmp_path / "mod.py" + src.write_text("x = 1\n", encoding="utf-8") + assert "passed" in run({"file_path": str(src)}) + + +def test_allowlist_permits_listed_root(tmp_path): + src = make_py_project(tmp_path, passing=True) + out = run({"file_path": str(src)}, + env_extra={"FABLE_TEST_HOOK_ALLOW": str(tmp_path)}) + assert "passed" in out, out + + +def test_allowlist_blocks_root_outside_it(tmp_path): + src = make_py_project(tmp_path, passing=True) + elsewhere = str(tmp_path.parent / "some-other-trusted-tree") + assert run({"file_path": str(src)}, + env_extra={"FABLE_TEST_HOOK_ALLOW": elsewhere}) == "" diff --git a/uninstall.py b/uninstall.py index 240574e..017ec65 100755 --- a/uninstall.py +++ b/uninstall.py @@ -95,7 +95,8 @@ def clean_settings(): if not os.path.isfile(path): return try: - d = json.load(open(path)) + with open(path) as f: + d = json.load(f) except Exception: print(" settings.json unreadable — left untouched") return @@ -122,7 +123,8 @@ def clean_settings(): elif "hooks" in d: d["hooks"] = hooks - json.dump(d, open(path, "w"), indent=2) + with open(path, "w") as f: + json.dump(d, f, indent=2) print(" removed Fable hooks from settings.json (backup: settings.json.uninstall.bak)") From 00f3afdd444adb22e57d36b5bebecad2953ac4fb Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:36:45 +0300 Subject: [PATCH 02/13] Make Fable activation reliable: launcher-declared mode + SessionStart injection The playbook injector had never fired: the effort path needs effort in the hook payload or CLAUDE_EFFORT in the hook env (absent before Claude Code 2.1.199), and the trigger phrases were undiscoverable. - fable-trigger.py is dual-event: SessionStart injects when the launcher declares FABLE_MODE=1 (startup/clear/compact always, resume respects the session marker); UserPromptSubmit keeps phrase/effort paths and gains a FABLE_MODE once-per-session fallback - launcher pins --model claude-opus-4-8 and appends the new fable-code.md, an original Claude Code-native distillation of Fable's terminal behavior (final-message contract, readable-over-concise, tool discipline, autonomy); the consumer prompt stays bundled for reference - new /fable skill for explicit mid-session activation - SessionStart registration in merge_settings + settings.fragment.json; uninstall strips it; round-trip uninstall test; playbook voice layer gains the Claude Code corrections --- CHANGELOG.md | 13 +++++ FABLE_PLAYBOOK.md | 18 ++++++ README.md | 4 +- fable-code.md | 68 ++++++++++++++++++++++ hooks/fable-trigger.py | 112 ++++++++++++++++++++++++------------ install.py | 3 + scripts/merge_settings.py | 3 + settings.fragment.json | 9 ++- shell/fable.ps1 | 24 +++++--- shell/fable.zsh | 18 +++--- skills/fable/SKILL.md | 25 ++++++++ tests/test_fable_trigger.py | 66 ++++++++++++++++++++- tests/test_install.py | 5 ++ tests/test_uninstall.py | 51 ++++++++++++++++ uninstall.py | 3 +- 15 files changed, 362 insertions(+), 60 deletions(-) create mode 100644 fable-code.md create mode 100644 skills/fable/SKILL.md create mode 100644 tests/test_uninstall.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e9edb..4ce4065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- The playbook injector never actually fired: the effort path needs `effort` in + the hook payload or `CLAUDE_EFFORT` in the hook environment, which Claude Code + ≤ 2.1.198 did not provide, and the trigger phrases were undiscoverable. The + launcher now declares the mode via `FABLE_MODE=1` and `fable-trigger.py` + became dual-event — SessionStart injection (version-independent) plus the old + phrase/effort paths and a `FABLE_MODE` fallback on UserPromptSubmit. - `merge_settings.py` overwrote `settings.json.bak` on every install run, so a second install destroyed the pristine pre-install backup (contradicting the "safe to re-run" promise). It now writes that backup only when absent, keeping @@ -23,6 +29,13 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `.lockb` skip entry. ### Added +- `fable-code.md`: an original Claude Code-native Fable behavior layer; the + launcher appends it instead of the 1,600-line consumer prompt (which stays + bundled for reference). +- `/fable` skill for explicit mid-session activation. +- The launcher pins `--model claude-opus-4-8`, making the README's "runs on + Opus 4.8" promise true regardless of the user's default model. +- Round-trip uninstall test (`tests/test_uninstall.py`). - `test-after-edit.py` gained a `FABLE_TEST_HOOK_ALLOW` allowlist (os.pathsep- separated trusted root prefixes) so the auto-run test hook can be confined to repositories you trust, plus a per-project `.fable-test` file to pin the exact diff --git a/FABLE_PLAYBOOK.md b/FABLE_PLAYBOOK.md index 8c4e58b..0128c9f 100644 --- a/FABLE_PLAYBOOK.md +++ b/FABLE_PLAYBOOK.md @@ -232,6 +232,24 @@ makes the work correct; this layer makes the output read like Fable wrote it. view of that file is stale — re-read the region before editing it again. (Extends ADOPT 3 past the first edit; Fable states this outright for `str_replace`.) +### The Claude Code layer (first-party, 2026-07) + +Observed first-party Fable 5 behavior in Claude Code adds three corrections to +the voice layer above — adopt them with the same weight: + +- **The final message is the deliverable.** Mid-turn text may never be shown; + answers, findings, and conclusions must all appear in the turn's last + message, outcome first. A perfect investigation with a buried conclusion + reads as no conclusion. +- **Readable beats concise.** Shorten by selecting what matters, not by + compressing prose into fragments, arrow chains ("A → B → fails"), or + shorthand the reader must reverse-engineer. Complete sentences; explain in + place. If the reader must reread or ask a follow-up, the brevity saved + nothing. +- **Assessment mode.** When the user is describing a problem or thinking out + loud rather than requesting a change, the deliverable is the assessment: + report findings and stop; don't apply the fix until asked. + --- ## Grounding — prove it before you call it done diff --git a/README.md b/README.md index 51f6cab..1a52d72 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,12 @@ Removes the bundled files from `~/.claude`, strips the `fable` launcher line, an - **`FABLE_PLAYBOOK.md`** — the core. Fable-5 vs Opus-4-8 tool traces turned into rules: reasoning density (70% vs 47%), verify-after-edit, parallelism — plus a voice layer and an evidence-ledger grounding protocol. Original work, not the leaked prompt. - **`fable-system.md`** — the leaked Fable 5 system prompt (Anthropic's; see note below). +- **`fable-code.md`** — an original Claude Code-native distillation of Fable's terminal behavior (final-message contract, readable-over-concise, tool discipline, autonomy rules). This is what the launcher actually appends; the consumer prompt above stays bundled for reference. +- **`/fable` skill** — explicit activation: reads the playbook + behavior layer and adopts both mid-session, no launcher required. - **Hooks** — `fable-trigger.py` injects the playbook at `xhigh`/`max`/`ultracode`; `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. - **`/ground` skill + `grounding-verifier` agent** — a self-terminating grounding loop and a cold verifier that assumes every claim is wrong until the live code proves it. - **Skills** — `claude-design-patterns` (web-UI engineering), `webapp-testing`, `mcp-builder`, `skill-creator`, `explore-data`. -- **`fable` launcher** — Opus 4.8 + the prompt + `xhigh` effort (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Swap `--effort xhigh` for `--settings '{"ultracode": true}'` in the launcher. +- **`fable` launcher** — pins `--model claude-opus-4-8`, appends `fable-code.md`, sets `xhigh` effort, and declares the mode via `FABLE_MODE=1` so the playbook injects at session start on every Claude Code version (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Add `--settings '{"ultracode": true}'`. ## The honest ceiling diff --git a/fable-code.md b/fable-code.md new file mode 100644 index 0000000..105ce8e --- /dev/null +++ b/fable-code.md @@ -0,0 +1,68 @@ +# Fable — Claude Code behavior layer + +Provenance: an original distillation of first-party Claude Fable 5 behavior +observed in Claude Code (2026-07). Not the leaked consumer prompt — that file +(`fable-system.md`) describes a chat tab; this one describes how Fable operates +in a terminal harness. Adopt everything below as standing discipline. + +## The final message is the deliverable + +Text between tool calls may never be shown; everything the user needs from a +turn — answers, findings, conclusions — must be in the last message, with no +tool calls after it. If something important surfaced mid-turn, restate it there. +Lead with the outcome: the first sentence answers "what happened" or "what did +you find"; supporting detail and reasoning come after, for readers who want them. + +## Readable beats concise + +Shorten by selecting what matters, not by compressing prose into fragments. +Complete sentences; technical terms spelled out; no arrow chains ("A → B → +fails"); no shorthand or codenames the reader must reverse-engineer; explain in +place instead of referencing labels or numbering invented earlier in the turn. +If the reader has to reread or ask a follow-up, the brevity saved nothing. +Simple questions get plain prose — no headers, no sections. Tables only for +short enumerable facts, explained in surrounding prose, not in the cells. + +## Working rhythm + +One sentence on intent before the first tool call. Surface load-bearing +findings and direction changes the moment they happen — one line each. +Otherwise, no narration between tool calls: no "Let me…", no result recaps, no +progress theater. Several tool calls in a row with no prose between them is +correct, not rude. + +## Tool discipline + +Batch independent tool calls in a single block; keep strict sequencing only +where a step consumes the previous result. Prefer structured tools (Grep, Glob, +Read) over shell pipelines; reference code as `file:line`. Read the exact +region you are about to edit, in this session, immediately before editing — +and your own successful edit invalidates your last read of that file. Absolute +paths, not `cd`. + +## Code and comments + +Write code that reads like the surrounding code — match its comment density, +naming, and idiom. A comment states only a constraint the code cannot show. +Never write comments that narrate the change, justify it to a reviewer, or say +where it came from; that is noise the moment the change lands. + +## Autonomy, honesty, and stopping + +Proceed without asking on reversible actions that follow from the request. +Stop and ask only for destructive actions, outward-facing effects (sending, +publishing), or genuine scope changes. Before deleting or overwriting, look at +the target; if it contradicts its description or you didn't create it, surface +that instead of proceeding. Report outcomes faithfully: failing tests are shown +with their output, skipped steps are named as skipped, and "done" is said only +after verification — an unverified edit is an untrue "done". + +When the user is describing a problem or thinking out loud, the deliverable is +your assessment: report findings and stop; don't apply a fix until asked. + +## The end-of-turn rule + +Before ending a turn, check the last paragraph. If it is a plan, a list of next +steps, a question a tool could answer, or a promise about work not yet done +("I'll…"), do that work now instead of ending the turn. End only when the task +is complete or blocked on input only the user can provide. diff --git a/hooks/fable-trigger.py b/hooks/fable-trigger.py index 8452e56..f1feeeb 100644 --- a/hooks/fable-trigger.py +++ b/hooks/fable-trigger.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 -"""UserPromptSubmit hook: load the Fable execution playbook on demand. - -Injects FABLE_PLAYBOOK.md into the turn's context when EITHER: - - the user's message contains a trigger phrase ("use fable" / "fable mode" / - "load fable"), or - - the active effort level is xhigh/max (so heavy mode follows the effort lever - without needing a phrase). - -The phrase path always injects (explicit intent; re-say after a compaction). The -effort path injects ONCE per session (marker file keyed by session_id) so it -doesn't re-inject the 12 KB playbook on every prompt. No phrase + low effort -> -nothing injected, so the playbook costs zero tokens by default. +"""Dual-event hook: load the Fable execution playbook when the mode is active. + +SessionStart When the `fable` launcher declared the mode (FABLE_MODE=1): + sources startup/clear/compact always inject (fresh or wiped + context); resume injects only if this session has no marker + yet. This path works on every Claude Code version — it does + not depend on the harness exposing effort to hooks. +UserPromptSubmit A trigger phrase ("use fable" / "fable mode" / "load fable") + always injects (explicit intent; re-say after a compaction). + Heavy effort (payload effort.level, else CLAUDE_EFFORT env) + or FABLE_MODE injects once per session (marker file keyed by + session_id) so the ~12 KB playbook isn't re-sent every prompt. + +No trigger -> prints nothing -> the playbook costs zero tokens by default. """ import sys import json @@ -33,46 +35,80 @@ def active_effort(data): return str(eff).strip().lower() +def fable_mode(): + return os.environ.get("FABLE_MODE", "").strip() == "1" + + +def marker_path(data): + sid = str(data.get("session_id") or "nosession") + sid = re.sub(r"[^A-Za-z0-9_-]", "_", sid) + return os.path.join(tempfile.gettempdir(), "fable-loaded-" + sid) + + +def write_marker(path): + try: + open(path, "w").close() + except Exception: + pass # marker is best-effort; worst case is one duplicate injection + + +def inject(event, why): + try: + with open(PLAYBOOK, encoding="utf-8") as f: + body = f.read() + except Exception: + return # playbook missing/unreadable: never block the session + context = ("Fable mode active ({}). Adopt the execution playbook below as " + "standing discipline for the rest of this session:\n\n".format(why) + + body) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context, + } + })) + + def main(): try: data = json.load(sys.stdin) except Exception: return # malformed input: never block the prompt + event = str(data.get("hook_event_name") or "UserPromptSubmit") + marker = marker_path(data) + + if event == "SessionStart": + if not fable_mode(): + return + source = str(data.get("source") or "startup").lower() + if source == "resume" and os.path.exists(marker): + return # context usually survives a resume; don't double-inject + write_marker(marker) + inject("SessionStart", "launcher") + return + prompt = data.get("prompt", "") or "" phrase = bool(TRIGGER.search(prompt)) - effort_heavy = active_effort(data) in HEAVY_EFFORT + effort = active_effort(data) + heavy = effort in HEAVY_EFFORT or fable_mode() - if not (phrase or effort_heavy): + if not (phrase or heavy): return - # Effort-only trigger: inject just once per session. - if effort_heavy and not phrase: - sid = str(data.get("session_id") or "nosession") - sid = re.sub(r"[^A-Za-z0-9_-]", "_", sid) - marker = os.path.join(tempfile.gettempdir(), f"fable-loaded-{sid}") + # Heavy-only trigger: inject just once per session. + if heavy and not phrase: if os.path.exists(marker): return - try: - open(marker, "w").close() - except Exception: - pass - - try: - with open(PLAYBOOK, encoding="utf-8") as f: - body = f.read() - except Exception: - return + write_marker(marker) - why = "phrase" if phrase else "effort=" + active_effort(data) - context = (f"Fable mode active ({why}). Adopt the execution playbook below as " - "standing discipline for the rest of this session:\n\n" + body) - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": context, - } - })) + if phrase: + why = "phrase" + elif effort in HEAVY_EFFORT: + why = "effort=" + effort + else: + why = "launcher" + inject("UserPromptSubmit", why) if __name__ == "__main__": diff --git a/install.py b/install.py index ab8eb2c..7bb7a95 100755 --- a/install.py +++ b/install.py @@ -101,6 +101,9 @@ def main(): print("-> fable system prompt") copy_into("fable-system.md", CLAUDE) + print("-> fable behavior layer (Claude Code)") + copy_into("fable-code.md", CLAUDE) + print("-> skills (all bundled) + agent") skills_dir = os.path.join(REPO, "skills") for name in sorted(os.listdir(skills_dir)): diff --git a/scripts/merge_settings.py b/scripts/merge_settings.py index f98e604..13c52cd 100644 --- a/scripts/merge_settings.py +++ b/scripts/merge_settings.py @@ -41,6 +41,9 @@ def ensure(event, entry, needle): for e in arr for h in e.get("hooks", [])): arr.append(entry) + ensure("SessionStart", + {"hooks": [{"type": "command", "command": cmd("fable-trigger.py")}]}, + "fable-trigger.py") ensure("UserPromptSubmit", {"hooks": [{"type": "command", "command": cmd("fable-trigger.py")}]}, "fable-trigger.py") diff --git a/settings.fragment.json b/settings.fragment.json index d9b88ea..50d2d9d 100644 --- a/settings.fragment.json +++ b/settings.fragment.json @@ -1,7 +1,14 @@ { - "_comment": "Reference only. install.sh / install.ps1 merge these into ~/.claude/settings.json (with a backup) and write ABSOLUTE interpreter + script paths so the hooks work without $HOME/python3 resolution. If editing by hand, replace 'python3' with your interpreter (e.g. 'python' on Windows) and $HOME with the full path to your home dir.", + "_comment": "Reference only. install.py merges these into ~/.claude/settings.json (with a backup) and writes ABSOLUTE interpreter + script paths so the hooks work without $HOME/python3 resolution. If editing by hand, replace 'python3' with your interpreter (e.g. 'python' on Windows) and $HOME with the full path to your home dir.", "alwaysThinkingEnabled": true, "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "python3 $HOME/.claude/hooks/fable-trigger.py" } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/shell/fable.ps1 b/shell/fable.ps1 index cb4fca7..ed5f954 100644 --- a/shell/fable.ps1 +++ b/shell/fable.ps1 @@ -1,15 +1,21 @@ # Fable mode launcher (PowerShell). Dot-source from your profile, or: # . C:\path\to\fable-mode\shell\fable.ps1 # -# Launches Claude Code (Opus 4.8) with the Fable 5 system prompt appended and -# xhigh effort — the heavy-reasoning lever that closes part of the measured -# 70-vs-47 reasoning-density gap prose alone can't. It also trips fable-trigger.py, -# which layers FABLE_PLAYBOOK execution discipline on top. +# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior +# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so +# fable-trigger.py injects the execution playbook at SessionStart — reliable on +# every Claude Code version, not only those that expose effort to hooks. # -# install.ps1 copies fable-system.md into ~\.claude for you. -# Want multi-agent auto-orchestration too? Swap `--effort xhigh` for -# `--settings '{"ultracode": true}'` — that sends xhigh AND auto-runs workflows for -# substantive tasks (the heaviest mode, and heavier on tokens). +# install.ps1 copies fable-code.md into ~\.claude for you. +# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' function fable { - claude --append-system-prompt-file "$HOME\.claude\fable-system.md" --effort xhigh @args + $env:FABLE_MODE = "1" + try { + claude --model claude-opus-4-8 ` + --append-system-prompt-file "$HOME\.claude\fable-code.md" ` + --effort xhigh @args + } + finally { + Remove-Item Env:FABLE_MODE -ErrorAction SilentlyContinue + } } diff --git a/shell/fable.zsh b/shell/fable.zsh index f61416a..6252799 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -1,14 +1,14 @@ # Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` # -# Launches Claude Code (Opus 4.8) with the Fable 5 system prompt appended and -# xhigh effort — the heavy-reasoning lever that closes part of the measured -# 70-vs-47 reasoning-density gap prose alone can't. It also trips fable-trigger.py, -# which layers FABLE_PLAYBOOK execution discipline on top. +# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior +# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so +# fable-trigger.py injects the execution playbook at SessionStart — reliable on +# every Claude Code version, not only those that expose effort to hooks. # -# install.sh copies fable-system.md into ~/.claude for you. -# Want multi-agent auto-orchestration too? Swap `--effort xhigh` for -# `--settings '{"ultracode": true}'` — that sends xhigh AND auto-runs workflows for -# substantive tasks (the heaviest mode, and heavier on tokens). +# install.py copies fable-code.md into ~/.claude for you. +# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' fable() { - claude --append-system-prompt-file "$HOME/.claude/fable-system.md" --effort xhigh "$@" + FABLE_MODE=1 claude --model claude-opus-4-8 \ + --append-system-prompt-file "$HOME/.claude/fable-code.md" \ + --effort xhigh "$@" } diff --git a/skills/fable/SKILL.md b/skills/fable/SKILL.md new file mode 100644 index 0000000..b152977 --- /dev/null +++ b/skills/fable/SKILL.md @@ -0,0 +1,25 @@ +--- +name: fable +description: Activate Fable mode - adopt the Fable execution playbook and the Claude Code behavior layer as standing discipline for this session. Use when the user types /fable, says "fable mode", "use fable", "work like fable", or asks for maximum-discipline execution. +--- + +# Fable mode + +Activate the full Fable discipline for the rest of this session: + +1. Read `~/.claude/FABLE_PLAYBOOK.md` (execution discipline: reason before + acting, observe-then-decide, verify every edit, communication floor, + grounding protocol). If the file is missing, read `FABLE_PLAYBOOK.md` from + this skill's repository instead. +2. Read `~/.claude/fable-code.md` (the Claude Code behavior layer: final-message + contract, readable-over-concise, tool discipline, autonomy and honesty + rules). Same fallback. +3. Adopt both as standing discipline — they govern every subsequent turn of + this session, not just the next reply. +4. Confirm activation in one line ("Fable mode active — playbook and behavior + layer loaded."), then continue with the user's task. Do not summarize the + documents back. + +Scale the heavier machinery to the task: the evidence ledger and the +`grounding-verifier` agent are for non-trivial or hard-to-reverse work, not for +typo fixes (the playbook's own calibration rules apply). diff --git a/tests/test_fable_trigger.py b/tests/test_fable_trigger.py index b07531b..b622dcc 100644 --- a/tests/test_fable_trigger.py +++ b/tests/test_fable_trigger.py @@ -10,11 +10,14 @@ HOOK = REPO / "hooks" / "fable-trigger.py" -def run(stdin_obj, home): +def run(stdin_obj, home, extra_env=None): env = dict(os.environ) env["HOME"] = str(home) # expanduser on POSIX env["USERPROFILE"] = str(home) # expanduser on Windows env.pop("CLAUDE_EFFORT", None) # effort is driven by the payload, not the dev's session + env.pop("FABLE_MODE", None) # ditto for the launcher flag + if extra_env: + env.update(extra_env) p = subprocess.run([sys.executable, str(HOOK)], input=json.dumps(stdin_obj), text=True, capture_output=True, env=env) @@ -64,3 +67,64 @@ def test_malformed_input_never_crashes(tmp_path): text=True, capture_output=True, env=env) assert p.returncode == 0 assert p.stdout.strip() == "" + + +def test_sessionstart_with_fable_mode_injects(tmp_path): + home = make_home(tmp_path) + out = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": str(uuid.uuid4())}, home, extra_env={"FABLE_MODE": "1"}) + assert out, "SessionStart under FABLE_MODE=1 should inject" + payload = json.loads(out)["hookSpecificOutput"] + assert payload["hookEventName"] == "SessionStart" + assert "PLAYBOOK_MARKER_42" in payload["additionalContext"] + + +def test_sessionstart_without_fable_mode_is_silent(tmp_path): + home = make_home(tmp_path) + out = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": str(uuid.uuid4())}, home) + assert out == "" + + +def test_sessionstart_resume_respects_marker_but_compact_reinjects(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + first = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, home, extra_env=env) + resume = run({"hook_event_name": "SessionStart", "source": "resume", + "session_id": sid}, home, extra_env=env) + compact = run({"hook_event_name": "SessionStart", "source": "compact", + "session_id": sid}, home, extra_env=env) + assert first, "startup should inject" + assert resume == "", "resume with marker should stay silent" + assert compact, "compact wiped context, must re-inject" + + +def test_fable_mode_env_injects_once_on_userpromptsubmit(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + first = run({"prompt": "hi", "session_id": sid}, home, extra_env=env) + second = run({"prompt": "hi again", "session_id": sid}, home, extra_env=env) + assert first, "FABLE_MODE should inject on the first prompt" + assert second == "", "and stay silent for the rest of the session" + + +def test_sessionstart_marker_suppresses_userpromptsubmit_repeat(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + ss = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, home, extra_env=env) + ups = run({"prompt": "hello", "session_id": sid}, home, extra_env=env) + assert ss, "SessionStart injects" + assert ups == "", "UserPromptSubmit must not double-inject the same session" + + +def test_effort_level_dict_payload_injects(tmp_path): + home = make_home(tmp_path) + out = run({"prompt": "hi", "effort": {"level": "xhigh"}, + "session_id": str(uuid.uuid4())}, home) + assert out, "effort.level dict form (documented payload) should inject" + diff --git a/tests/test_install.py b/tests/test_install.py index a5b4c7b..110bc0e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -36,6 +36,8 @@ def test_install_copies_everything(tmp_path): assert (claude / "FABLE_PLAYBOOK.md").is_file() assert (claude / "fable-system.md").is_file() + assert (claude / "fable-code.md").is_file() + assert (claude / "skills" / "fable" / "SKILL.md").is_file() assert (claude / "hooks" / "fable-trigger.py").is_file() assert (claude / "hooks" / "test-after-edit.py").is_file() assert (claude / "agents" / "grounding-verifier.md").is_file() @@ -49,6 +51,8 @@ def test_install_copies_everything(tmp_path): assert s["alwaysThinkingEnabled"] is True cmd = s["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] assert sys.executable in cmd # absolute interpreter, not a bare "python3" + ss_cmd = s["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "fable-trigger.py" in ss_cmd def test_install_is_idempotent(tmp_path): @@ -60,6 +64,7 @@ def test_install_is_idempotent(tmp_path): s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) assert len(s["hooks"]["UserPromptSubmit"]) == 1 assert len(s["hooks"]["PostToolUse"]) == 1 + assert len(s["hooks"]["SessionStart"]) == 1 assert (tmp_path / "profile.ps1").read_text(encoding="utf-8").count("fable.ps1") == 1 assert (claude / "settings.json.bak").is_file() diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py new file mode 100644 index 0000000..38d5288 --- /dev/null +++ b/tests/test_uninstall.py @@ -0,0 +1,51 @@ +"""Round-trip test: install into a sandbox home, uninstall, verify clean state.""" +import importlib.util +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, REPO / filename) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def sandbox(mod, tmp_path, windows=True): + mod.HOME = str(tmp_path) + mod.CLAUDE = str(tmp_path / ".claude") + mod.IS_WINDOWS = windows + mod.powershell_profile_path = lambda: str(tmp_path / "profile.ps1") + return mod + + +def test_uninstall_reverses_install(tmp_path): + inst = sandbox(load("fable_install", "install.py"), tmp_path) + uninst = sandbox(load("fable_uninstall", "uninstall.py"), tmp_path) + inst.main() + + claude = tmp_path / ".claude" + # plant a foreign hook that must survive + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + s["hooks"]["SessionStart"].append( + {"hooks": [{"type": "command", "command": "node keepme.cjs"}]}) + (claude / "settings.json").write_text(json.dumps(s, indent=2), encoding="utf-8") + + uninst.main() + + assert not (claude / "FABLE_PLAYBOOK.md").exists() + assert not (claude / "fable-system.md").exists() + assert not (claude / "fable-code.md").exists() + assert not (claude / "hooks" / "fable-trigger.py").exists() + assert not (claude / "skills" / "fable").exists() + assert not (claude / "skills" / "ground").exists() + assert "fable.ps1" not in (tmp_path / "profile.ps1").read_text(encoding="utf-8") + + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + ss = s["hooks"]["SessionStart"] + assert len(ss) == 1 and "keepme.cjs" in ss[0]["hooks"][0]["command"] + assert "UserPromptSubmit" not in s["hooks"] + assert "PostToolUse" not in s["hooks"] + assert s["alwaysThinkingEnabled"] is True diff --git a/uninstall.py b/uninstall.py index 017ec65..82ffa8f 100755 --- a/uninstall.py +++ b/uninstall.py @@ -103,7 +103,7 @@ def clean_settings(): shutil.copy(path, path + ".uninstall.bak") hooks = d.get("hooks", {}) - for event in ("UserPromptSubmit", "PostToolUse"): + for event in ("SessionStart", "UserPromptSubmit", "PostToolUse"): arr = hooks.get(event) if not isinstance(arr, list): continue @@ -134,6 +134,7 @@ def main(): rm_file(os.path.join(CLAUDE, "hooks", "test-after-edit.py")) rm_file(os.path.join(CLAUDE, "FABLE_PLAYBOOK.md")) rm_file(os.path.join(CLAUDE, "fable-system.md")) + rm_file(os.path.join(CLAUDE, "fable-code.md")) rm_file(os.path.join(CLAUDE, "agents", "grounding-verifier.md")) for name in bundled_skill_names(): rm_tree(os.path.join(CLAUDE, "skills", name)) From 56513b794293644aa1c65379e46137829879b89d Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:37:04 +0300 Subject: [PATCH 03/13] Add auto-activation, --ultra orchestration, and fable doctor - fable-trigger.py scores prompt complexity (ru+en task verbs, code fences, file paths, multi-step markers, length; >= 2 points) and loads the playbook by itself for task-shaped prompts, once per session, in any session; opt out with FABLE_AUTO=0 - fable --ultra / -u launches with ultracode auto-orchestration; the playbook gains an Orchestration section (fan-out, adversarial verification via grounding-verifier, plan-gating, calibration) - fable doctor (hooks/fable-doctor.py) verifies the whole chain: files, registered hooks, interpreter paths, Claude Code version, a live-fire injection test, transcript evidence; FABLE_DOCTOR_SKIP_CLI=1 for offline runs - the /fable skill triggers proactively on non-trivial tasks and skips the file reads when the playbook is already in context --- CHANGELOG.md | 11 ++ FABLE_PLAYBOOK.md | 22 ++++ README.md | 4 +- hooks/fable-doctor.py | 199 ++++++++++++++++++++++++++++++++++++ hooks/fable-trigger.py | 45 +++++++- install.py | 1 + shell/fable.ps1 | 25 +++-- shell/fable.zsh | 25 +++-- skills/fable/SKILL.md | 6 +- tests/test_fable_doctor.py | 78 ++++++++++++++ tests/test_fable_trigger.py | 28 +++++ tests/test_install.py | 1 + tests/test_uninstall.py | 1 + uninstall.py | 1 + 14 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 hooks/fable-doctor.py create mode 100644 tests/test_fable_doctor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce4065..3db0d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `.lockb` skip entry. ### Added +- Auto-activation: `fable-trigger.py` scores prompt complexity (ru+en signals: + task verbs, code fences, file paths, multi-step markers, length) and loads + the playbook by itself for task-shaped prompts, once per session, in any + session — no launcher or phrase needed. Opt out with `FABLE_AUTO=0`. +- `fable --ultra` (alias `-u`): launches with ultracode auto-orchestration; new + "Orchestration" section in the playbook (fan-out, adversarial verification + via `grounding-verifier`, plan-gating, calibration). +- `fable doctor`: one-command diagnosis of the install/activation chain — + files, registered hooks, interpreter paths, Claude Code version, a live-fire + injection test, and transcript evidence of past activations. +- The `/fable` skill triggers proactively at the start of non-trivial tasks. - `fable-code.md`: an original Claude Code-native Fable behavior layer; the launcher appends it instead of the 1,600-line consumer prompt (which stays bundled for reference). diff --git a/FABLE_PLAYBOOK.md b/FABLE_PLAYBOOK.md index 0128c9f..632682b 100644 --- a/FABLE_PLAYBOOK.md +++ b/FABLE_PLAYBOOK.md @@ -338,3 +338,25 @@ levers the harness actually enforces: auto-memory (relevance-gated, may not surface on a given turn). Point sessions at this file and *The Fable Mindset* deliberately; wire the hook and effort level as the hard guarantees. + +--- + +## Orchestration — scale the harness to the task + +Fable's discipline is single-context by default; escalate deliberately: + +- **Fan out when the task decomposes.** Independent units (N files to migrate, + M subsystems to map, review dimensions) → parallel subagents, each with a + scoped brief; synthesize in the main context. Never serialize what has no + data dependency (Fix 3, applied to agents). +- **Verify adversarially.** Fan-in results are claims, not facts: route + non-trivial ones through the evidence ledger, and spawn the cold + `grounding-verifier` on the merged result — a subagent never self-approves + its own output (Grounding section above). +- **Plan-gate before long autonomy.** Multi-phase runs get a phased plan and a + live task list first; return to the plan at each phase boundary. +- **Calibrate ruthlessly.** One context that fits the whole task beats any + orchestration. No multi-agent machinery for typo-class work. +- **Mechanical lever:** `fable --ultra` launches with `ultracode` — the harness + auto-runs multi-agent workflows for substantive tasks. Heavy on tokens; the + calibration rule above is the counterweight. diff --git a/README.md b/README.md index 1a52d72..4a3bd37 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ Removes the bundled files from `~/.claude`, strips the `fable` launcher line, an - **`fable-system.md`** — the leaked Fable 5 system prompt (Anthropic's; see note below). - **`fable-code.md`** — an original Claude Code-native distillation of Fable's terminal behavior (final-message contract, readable-over-concise, tool discipline, autonomy rules). This is what the launcher actually appends; the consumer prompt above stays bundled for reference. - **`/fable` skill** — explicit activation: reads the playbook + behavior layer and adopts both mid-session, no launcher required. -- **Hooks** — `fable-trigger.py` injects the playbook at `xhigh`/`max`/`ultracode`; `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. +- **Hooks** — `fable-trigger.py` injects the playbook when the launcher declares the mode, at `xhigh`/`max`/`ultracode` effort, on a trigger phrase, or **by itself when the prompt looks like a real task** (ru+en heuristic; opt out with `FABLE_AUTO=0`); `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. - **`/ground` skill + `grounding-verifier` agent** — a self-terminating grounding loop and a cold verifier that assumes every claim is wrong until the live code proves it. - **Skills** — `claude-design-patterns` (web-UI engineering), `webapp-testing`, `mcp-builder`, `skill-creator`, `explore-data`. -- **`fable` launcher** — pins `--model claude-opus-4-8`, appends `fable-code.md`, sets `xhigh` effort, and declares the mode via `FABLE_MODE=1` so the playbook injects at session start on every Claude Code version (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Add `--settings '{"ultracode": true}'`. +- **`fable` launcher** — pins `--model claude-opus-4-8`, appends `fable-code.md`, sets `xhigh` effort, and declares the mode via `FABLE_MODE=1` so the playbook injects at session start on every Claude Code version (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). `fable --ultra` adds ultracode multi-agent orchestration; `fable doctor` verifies the whole install/activation chain in one command. ## The honest ceiling diff --git a/hooks/fable-doctor.py b/hooks/fable-doctor.py new file mode 100644 index 0000000..c18e22b --- /dev/null +++ b/hooks/fable-doctor.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""fable doctor: verify the Fable mode install/activation chain end to end. + +Run directly or via the `fable doctor` launcher subcommand. Exit code 1 only +on hard failures (missing core files, unparseable/unregistered settings, +live-fire failure); warnings and info lines never fail the run. + +Env knobs: + FABLE_DOCTOR_SKIP_CLI=1 skip the `claude --version` probe (offline/tests) +""" +import glob +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import uuid + +HOME = os.path.expanduser("~") +CLAUDE = os.path.join(HOME, ".claude") + +FAILS = [] +WARNS = [] + + +def ok(msg): + print("[ok] " + msg) + + +def fail(msg): + FAILS.append(msg) + print("[!!] " + msg) + + +def warn(msg): + WARNS.append(msg) + print("[--] " + msg) + + +def check_python(): + if sys.version_info >= (3, 9): + ok("python {}.{}.{}".format(*sys.version_info[:3])) + else: + fail("python >= 3.9 required, running {}.{}".format(*sys.version_info[:2])) + + +CORE_FILES = [ + "FABLE_PLAYBOOK.md", + "fable-code.md", + "fable-system.md", + os.path.join("hooks", "fable-trigger.py"), + os.path.join("hooks", "test-after-edit.py"), + os.path.join("skills", "fable", "SKILL.md"), + os.path.join("agents", "grounding-verifier.md"), +] + + +def check_files(): + for rel in CORE_FILES: + path = os.path.join(CLAUDE, rel) + if os.path.isfile(path): + ok("present: " + rel) + else: + fail("missing: " + path + " (re-run install.py)") + + +def check_settings(): + path = os.path.join(CLAUDE, "settings.json") + if not os.path.isfile(path): + fail("settings.json missing (re-run install.py)") + return + try: + with open(path, encoding="utf-8") as f: + d = json.load(f) + except Exception as e: + fail("settings.json unreadable: {}".format(e)) + return + hooks = d.get("hooks", {}) + for event, needle in (("SessionStart", "fable-trigger.py"), + ("UserPromptSubmit", "fable-trigger.py"), + ("PostToolUse", "test-after-edit.py")): + cmds = [h.get("command", "") for e in hooks.get(event, []) + for h in e.get("hooks", [])] + hit = next((c for c in cmds if needle in c), None) + if not hit: + fail("{} not registered for {} (re-run install.py)".format(needle, event)) + continue + ok("{} registered for {}".format(needle, event)) + m = re.match(r'"([^"]+)"', hit) + interp = m.group(1) if m else hit.split()[0] + if not (os.path.isfile(interp) or shutil.which(interp)): + fail("hook interpreter not found: " + interp) + if d.get("alwaysThinkingEnabled") is not True: + warn("alwaysThinkingEnabled is off - reasoning-density lever missing") + + +def check_claude_cli(): + if os.environ.get("FABLE_DOCTOR_SKIP_CLI") == "1": + warn("claude CLI probe skipped (FABLE_DOCTOR_SKIP_CLI=1)") + return + if not shutil.which("claude"): + warn("claude CLI not on PATH - the fable launcher won't start") + return + try: + out = subprocess.run(["claude", "--version"], capture_output=True, + text=True, timeout=30).stdout.strip() + except Exception as e: + warn("claude --version failed: {}".format(e)) + return + m = re.search(r"(\d+)\.(\d+)\.(\d+)", out) + if not m: + warn("could not parse claude version from: " + out) + return + ver = tuple(int(x) for x in m.groups()) + label = ".".join(map(str, ver)) + if ver >= (2, 1, 199): + ok("claude {} (effort exposed to hooks)".format(label)) + else: + warn("claude {} < 2.1.199: effort not exposed to hooks; " + "phrase/FABLE_MODE/SessionStart paths unaffected".format(label)) + + +def _fire(payload, env_extra): + hook = os.path.join(CLAUDE, "hooks", "fable-trigger.py") + env = dict(os.environ) + env.pop("CLAUDE_EFFORT", None) + env.pop("FABLE_MODE", None) + env.update(env_extra) + p = subprocess.run([sys.executable, hook], input=json.dumps(payload), + capture_output=True, text=True, timeout=30, env=env) + return p.stdout.strip() + + +def check_live_fire(): + if not os.path.isfile(os.path.join(CLAUDE, "hooks", "fable-trigger.py")): + return # already reported by check_files + sid = "doctor-" + uuid.uuid4().hex[:8] + try: + ss = _fire({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, {"FABLE_MODE": "1"}) + ups = _fire({"prompt": "use fable", "session_id": sid + "-b"}, {}) + except Exception as e: + fail("live fire errored: {}".format(e)) + return + finally: + for s in (sid, sid + "-b"): + try: + os.remove(os.path.join(tempfile.gettempdir(), "fable-loaded-" + s)) + except OSError: + pass + for name, out, event in (("SessionStart", ss, "SessionStart"), + ("UserPromptSubmit phrase", ups, "UserPromptSubmit")): + try: + payload = json.loads(out)["hookSpecificOutput"] + assert payload["hookEventName"] == event + assert "Fable mode active" in payload["additionalContext"] + ok("live fire: {} injects".format(name)) + except Exception: + fail("live fire: {} produced no/invalid injection: {!r}".format( + name, out[:80])) + + +def check_evidence(): + files = glob.glob(os.path.join(CLAUDE, "projects", "*", "*.jsonl")) + files.sort(key=os.path.getmtime, reverse=True) + for path in files[:10]: + try: + with open(path, encoding="utf-8", errors="ignore") as f: + for line in f: + if "Fable mode active" in line: + ok("injection seen in a recent session: " + + os.path.basename(path)) + return + except OSError: + continue + warn("no injection found in the 10 most recent transcripts " + "(fine if you haven't used fable yet)") + + +def main(): + print("fable doctor - checking " + CLAUDE) + check_python() + check_files() + check_settings() + check_claude_cli() + check_live_fire() + check_evidence() + print() + if FAILS: + print("RESULT: {} failure(s), {} warning(s) - Fable mode will not work " + "correctly.".format(len(FAILS), len(WARNS))) + sys.exit(1) + print("RESULT: healthy ({} warning(s)).".format(len(WARNS))) + + +if __name__ == "__main__": + main() diff --git a/hooks/fable-trigger.py b/hooks/fable-trigger.py index f1feeeb..ea158a3 100644 --- a/hooks/fable-trigger.py +++ b/hooks/fable-trigger.py @@ -8,9 +8,10 @@ not depend on the harness exposing effort to hooks. UserPromptSubmit A trigger phrase ("use fable" / "fable mode" / "load fable") always injects (explicit intent; re-say after a compaction). - Heavy effort (payload effort.level, else CLAUDE_EFFORT env) - or FABLE_MODE injects once per session (marker file keyed by - session_id) so the ~12 KB playbook isn't re-sent every prompt. + Heavy effort (payload effort.level, else CLAUDE_EFFORT env), + FABLE_MODE, or a task-shaped prompt (looks_complex heuristic; + disable with FABLE_AUTO=0) injects once per session (marker + file keyed by session_id). No trigger -> prints nothing -> the playbook costs zero tokens by default. """ @@ -24,6 +25,37 @@ TRIGGER = re.compile(r"\b(use fable|fable mode|load fable)\b", re.I) HEAVY_EFFORT = {"xhigh", "max", "ultracode"} +# Auto-activation heuristic: score-based guess that a prompt is a real +# engineering task. >= 2 points = complex. Disable with FABLE_AUTO=0. +TASK_VERBS = re.compile( + r"\b(implement|refactor|migrat\w*|build|creat\w*|add|fix|debug|integrat\w*|" + r"optimi[sz]\w*|rewrit\w*|design|install|set up" + r"|сдела\w*|добав\w*|почин\w*|исправ\w*|реализу\w*|перепиш\w*|настро\w*|" + r"созда\w*|собер\w*|интегрир\w*|оптимизир\w*|мигрир\w*|разработ\w*)", re.I) +MULTISTEP = re.compile(r"\b(затем|потом|после этого|then|steps?)\b|^\s*\d+[.)]\s", + re.I | re.M) +PATHISH = re.compile( + r"\w+\.(py|js|ts|tsx|java|go|rs|c|cpp|h|cs|rb|php|md|json|ya?ml|toml|ps1|sh|zsh)\b" + r"|[/\\][\w.-]+[/\\]") +FENCE_OR_TRACE = re.compile(r"```|Traceback \(most recent call last\)|^\s*File \"", + re.M) + + +def looks_complex(prompt): + score = 0 + if len(prompt) >= 400: + score += 2 + if FENCE_OR_TRACE.search(prompt): + score += 2 + score += min(len(set(m.group(0).lower() for m in TASK_VERBS.finditer(prompt))), 2) + if PATHISH.search(prompt): + score += 1 + if MULTISTEP.search(prompt): + score += 1 + if len([ln for ln in prompt.splitlines() if ln.strip()]) >= 3: + score += 1 + return score >= 2 + def active_effort(data): """Effort from the hook JSON (effort.level or effort string), else env.""" @@ -91,7 +123,8 @@ def main(): prompt = data.get("prompt", "") or "" phrase = bool(TRIGGER.search(prompt)) effort = active_effort(data) - heavy = effort in HEAVY_EFFORT or fable_mode() + auto = os.environ.get("FABLE_AUTO", "").strip() != "0" and looks_complex(prompt) + heavy = effort in HEAVY_EFFORT or fable_mode() or auto if not (phrase or heavy): return @@ -106,8 +139,10 @@ def main(): why = "phrase" elif effort in HEAVY_EFFORT: why = "effort=" + effort - else: + elif fable_mode(): why = "launcher" + else: + why = "auto" inject("UserPromptSubmit", why) diff --git a/install.py b/install.py index 7bb7a95..33a5c6a 100755 --- a/install.py +++ b/install.py @@ -94,6 +94,7 @@ def main(): print("-> hooks") copy_into("hooks/fable-trigger.py", os.path.join(CLAUDE, "hooks")) copy_into("hooks/test-after-edit.py", os.path.join(CLAUDE, "hooks")) + copy_into("hooks/fable-doctor.py", os.path.join(CLAUDE, "hooks")) print("-> playbook") copy_into("FABLE_PLAYBOOK.md", CLAUDE) diff --git a/shell/fable.ps1 b/shell/fable.ps1 index ed5f954..b58aedd 100644 --- a/shell/fable.ps1 +++ b/shell/fable.ps1 @@ -1,19 +1,30 @@ # Fable mode launcher (PowerShell). Dot-source from your profile, or: # . C:\path\to\fable-mode\shell\fable.ps1 # -# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior -# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so -# fable-trigger.py injects the execution playbook at SessionStart — reliable on -# every Claude Code version, not only those that expose effort to hooks. +# `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior +# layer appended, xhigh effort, FABLE_MODE=1 declared so +# fable-trigger.py injects the playbook at SessionStart. +# `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent +# workflows for substantive tasks (heavy on tokens). +# `fable doctor` Verify the whole install/activation chain mechanically. # -# install.ps1 copies fable-code.md into ~\.claude for you. -# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' +# install.ps1 copies fable-code.md and fable-doctor.py into ~\.claude for you. function fable { + $rest = @($args) + if ($rest.Count -gt 0 -and $rest[0] -eq 'doctor') { + & python "$HOME\.claude\hooks\fable-doctor.py" @($rest | Select-Object -Skip 1) + return + } + $extra = @() + if ($rest.Count -gt 0 -and ($rest[0] -eq '--ultra' -or $rest[0] -eq '-u')) { + $rest = @($rest | Select-Object -Skip 1) + $extra = @('--settings', '{"ultracode": true}') + } $env:FABLE_MODE = "1" try { claude --model claude-opus-4-8 ` --append-system-prompt-file "$HOME\.claude\fable-code.md" ` - --effort xhigh @args + --effort xhigh @extra @rest } finally { Remove-Item Env:FABLE_MODE -ErrorAction SilentlyContinue diff --git a/shell/fable.zsh b/shell/fable.zsh index 6252799..9cda84b 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -1,14 +1,25 @@ # Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` # -# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior -# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so -# fable-trigger.py injects the execution playbook at SessionStart — reliable on -# every Claude Code version, not only those that expose effort to hooks. +# `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior +# layer appended, xhigh effort, FABLE_MODE=1 declared so +# fable-trigger.py injects the playbook at SessionStart. +# `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent +# workflows for substantive tasks (heavy on tokens). +# `fable doctor` Verify the whole install/activation chain mechanically. # -# install.py copies fable-code.md into ~/.claude for you. -# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' +# install.py copies fable-code.md and fable-doctor.py into ~/.claude for you. fable() { + if [[ "$1" == "doctor" ]]; then + shift + python3 "$HOME/.claude/hooks/fable-doctor.py" "$@" + return + fi + local -a extra + if [[ "$1" == "--ultra" || "$1" == "-u" ]]; then + shift + extra=(--settings '{"ultracode": true}') + fi FABLE_MODE=1 claude --model claude-opus-4-8 \ --append-system-prompt-file "$HOME/.claude/fable-code.md" \ - --effort xhigh "$@" + --effort xhigh "${extra[@]}" "$@" } diff --git a/skills/fable/SKILL.md b/skills/fable/SKILL.md index b152977..ee49900 100644 --- a/skills/fable/SKILL.md +++ b/skills/fable/SKILL.md @@ -1,6 +1,6 @@ --- name: fable -description: Activate Fable mode - adopt the Fable execution playbook and the Claude Code behavior layer as standing discipline for this session. Use when the user types /fable, says "fable mode", "use fable", "work like fable", or asks for maximum-discipline execution. +description: Activate Fable mode - adopt the Fable execution playbook and the Claude Code behavior layer as standing discipline for this session. Use when the user types /fable, says "fable mode", "use fable", "work like fable", or asks for maximum-discipline execution - and PROACTIVELY at the start of any non-trivial engineering task (multi-step implementation, refactor, migration, long debugging session) when the playbook is not already in context. --- # Fable mode @@ -20,6 +20,10 @@ Activate the full Fable discipline for the rest of this session: layer loaded."), then continue with the user's task. Do not summarize the documents back. +If the playbook is already in this context (look for a "Fable mode active" +injection from the hook), skip the reads: confirm activation in one line and +apply `fable-code.md` from context, reading it only if absent. + Scale the heavier machinery to the task: the evidence ledger and the `grounding-verifier` agent are for non-trivial or hard-to-reverse work, not for typo fixes (the playbook's own calibration rules apply). diff --git a/tests/test_fable_doctor.py b/tests/test_fable_doctor.py new file mode 100644 index 0000000..105b970 --- /dev/null +++ b/tests/test_fable_doctor.py @@ -0,0 +1,78 @@ +"""Tests for hooks/fable-doctor.py — the install/activation chain checker.""" +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +DOCTOR = REPO / "hooks" / "fable-doctor.py" + + +def run_doctor(home): + env = dict(os.environ) + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["FABLE_DOCTOR_SKIP_CLI"] = "1" # offline + fast in CI + env.pop("CLAUDE_EFFORT", None) + env.pop("FABLE_MODE", None) + return subprocess.run([sys.executable, str(DOCTOR)], capture_output=True, + text=True, env=env) + + +def fake_install(home): + claude = home / ".claude" + (claude / "hooks").mkdir(parents=True) + (claude / "skills" / "fable").mkdir(parents=True) + (claude / "agents").mkdir(parents=True) + for rel in ("FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md"): + (claude / rel).write_text("PLAYBOOK", encoding="utf-8") + shutil.copy(REPO / "hooks" / "fable-trigger.py", + claude / "hooks" / "fable-trigger.py") + (claude / "hooks" / "test-after-edit.py").write_text("# stub", encoding="utf-8") + (claude / "hooks" / "fable-doctor.py").write_text("# stub", encoding="utf-8") + (claude / "skills" / "fable" / "SKILL.md").write_text("---\nname: fable\n---", + encoding="utf-8") + (claude / "agents" / "grounding-verifier.md").write_text("x", encoding="utf-8") + trigger = str(claude / "hooks" / "fable-trigger.py") + tester = str(claude / "hooks" / "test-after-edit.py") + settings = { + "alwaysThinkingEnabled": True, + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, trigger)}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, trigger)}]}], + "PostToolUse": [{"matcher": "Edit|Write|MultiEdit", + "hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, tester)}]}], + }, + } + (claude / "settings.json").write_text(json.dumps(settings), encoding="utf-8") + + +def test_fresh_home_fails_with_named_missing_files(tmp_path): + p = run_doctor(tmp_path) + assert p.returncode == 1 + assert "missing" in p.stdout + assert "FABLE_PLAYBOOK.md" in p.stdout + + +def test_healthy_install_passes_live_fire(tmp_path): + fake_install(tmp_path) + p = run_doctor(tmp_path) + assert "live fire: SessionStart injects" in p.stdout, p.stdout + assert "live fire: UserPromptSubmit phrase injects" in p.stdout, p.stdout + assert p.returncode == 0, p.stdout + + +def test_unregistered_hook_is_a_hard_failure(tmp_path): + fake_install(tmp_path) + claude = tmp_path / ".claude" + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + del s["hooks"]["SessionStart"] + (claude / "settings.json").write_text(json.dumps(s), encoding="utf-8") + p = run_doctor(tmp_path) + assert p.returncode == 1 + assert "not registered for SessionStart" in p.stdout diff --git a/tests/test_fable_trigger.py b/tests/test_fable_trigger.py index b622dcc..e3a44ab 100644 --- a/tests/test_fable_trigger.py +++ b/tests/test_fable_trigger.py @@ -16,6 +16,7 @@ def run(stdin_obj, home, extra_env=None): env["USERPROFILE"] = str(home) # expanduser on Windows env.pop("CLAUDE_EFFORT", None) # effort is driven by the payload, not the dev's session env.pop("FABLE_MODE", None) # ditto for the launcher flag + env.pop("FABLE_AUTO", None) # and the auto-heuristic knob if extra_env: env.update(extra_env) p = subprocess.run([sys.executable, str(HOOK)], @@ -128,3 +129,30 @@ def test_effort_level_dict_payload_injects(tmp_path): "session_id": str(uuid.uuid4())}, home) assert out, "effort.level dict form (documented payload) should inject" + +def test_complex_prompt_auto_injects_once(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + prompt = "Сделай рефакторинг hooks/fable-trigger.py и добавь тесты, затем обнови README" + first = run({"prompt": prompt, "session_id": sid}, home) + second = run({"prompt": prompt, "session_id": sid}, home) + assert first, "task-shaped prompt should auto-inject" + ctx = json.loads(first)["hookSpecificOutput"]["additionalContext"] + assert "Fable mode active (auto)" in ctx + assert second == "", "auto path is once per session" + + +def test_fable_auto_opt_out(tmp_path): + home = make_home(tmp_path) + prompt = "Implement a REST endpoint in api/server.py and fix the failing tests" + out = run({"prompt": prompt, "session_id": str(uuid.uuid4())}, home, + extra_env={"FABLE_AUTO": "0"}) + assert out == "", "FABLE_AUTO=0 must disable the heuristic" + + +def test_simple_greeting_stays_silent(tmp_path): + home = make_home(tmp_path) + assert run({"prompt": "привет, как дела?", + "session_id": str(uuid.uuid4())}, home) == "" + assert run({"prompt": "what time is it", + "session_id": str(uuid.uuid4())}, home) == "" diff --git a/tests/test_install.py b/tests/test_install.py index 110bc0e..3b0198d 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -40,6 +40,7 @@ def test_install_copies_everything(tmp_path): assert (claude / "skills" / "fable" / "SKILL.md").is_file() assert (claude / "hooks" / "fable-trigger.py").is_file() assert (claude / "hooks" / "test-after-edit.py").is_file() + assert (claude / "hooks" / "fable-doctor.py").is_file() assert (claude / "agents" / "grounding-verifier.md").is_file() assert (claude / "skills" / "ground" / "SKILL.md").is_file() # nested skill content must survive the copy diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 38d5288..d4277bd 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -39,6 +39,7 @@ def test_uninstall_reverses_install(tmp_path): assert not (claude / "fable-system.md").exists() assert not (claude / "fable-code.md").exists() assert not (claude / "hooks" / "fable-trigger.py").exists() + assert not (claude / "hooks" / "fable-doctor.py").exists() assert not (claude / "skills" / "fable").exists() assert not (claude / "skills" / "ground").exists() assert "fable.ps1" not in (tmp_path / "profile.ps1").read_text(encoding="utf-8") diff --git a/uninstall.py b/uninstall.py index 82ffa8f..0c78b90 100755 --- a/uninstall.py +++ b/uninstall.py @@ -132,6 +132,7 @@ def main(): print("-> files in ~/.claude") rm_file(os.path.join(CLAUDE, "hooks", "fable-trigger.py")) rm_file(os.path.join(CLAUDE, "hooks", "test-after-edit.py")) + rm_file(os.path.join(CLAUDE, "hooks", "fable-doctor.py")) rm_file(os.path.join(CLAUDE, "FABLE_PLAYBOOK.md")) rm_file(os.path.join(CLAUDE, "fable-system.md")) rm_file(os.path.join(CLAUDE, "fable-code.md")) From 061b42cfb39c167dffbd5f83e9e89057b77cb213 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:37:23 +0300 Subject: [PATCH 04/13] Add design specs and implementation plans for the activation work --- .../2026-07-03-fable-auto-orchestration.md | 721 +++++++++++++++++ .../plans/2026-07-03-fable-mode-rework.md | 724 ++++++++++++++++++ ...6-07-03-fable-auto-orchestration-design.md | 114 +++ .../2026-07-03-fable-mode-rework-design.md | 142 ++++ 4 files changed, 1701 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-fable-auto-orchestration.md create mode 100644 docs/superpowers/plans/2026-07-03-fable-mode-rework.md create mode 100644 docs/superpowers/specs/2026-07-03-fable-auto-orchestration-design.md create mode 100644 docs/superpowers/specs/2026-07-03-fable-mode-rework-design.md diff --git a/docs/superpowers/plans/2026-07-03-fable-auto-orchestration.md b/docs/superpowers/plans/2026-07-03-fable-auto-orchestration.md new file mode 100644 index 0000000..48b4ef0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-fable-auto-orchestration.md @@ -0,0 +1,721 @@ +# Fable Auto-Activation, Orchestration & Doctor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complex prompts activate Fable mode by themselves in any session; `fable --ultra` enables ultracode orchestration; `fable doctor` verifies the whole install/activation chain. + +**Architecture:** A score-based `looks_complex()` heuristic joins the existing "heavy" sources in `fable-trigger.py`'s UserPromptSubmit branch (once-per-session, `FABLE_AUTO=0` opt-out); the `/fable` skill description gains a proactive clause; both launchers get first-argument dispatch for `doctor` and `--ultra`; a new stdlib-only `hooks/fable-doctor.py` runs presence/settings/version/live-fire/evidence checks. + +**Tech Stack:** Python ≥ 3.9 stdlib only, pytest, zsh/bash + PowerShell. + +## Global Constraints + +- Python 3.9+, stdlib only, no pip installs (README promise). +- Hooks never crash the session: exit 0, silent on error. +- Tests must pass on Windows AND POSIX. +- **Commits are deferred** — user WIP is intermixed in the working tree; the user slices commits. No AI attribution in any eventual commit message (user's global rule). +- Spec: `docs/superpowers/specs/2026-07-03-fable-auto-orchestration-design.md`. + +--- + +### Task 1: Complexity heuristic in `fable-trigger.py` + +**Files:** +- Modify: `hooks/fable-trigger.py` +- Test: `tests/test_fable_trigger.py` + +**Interfaces:** +- Produces: `looks_complex(prompt) -> bool` (score ≥ 2); env knob `FABLE_AUTO` ("0" disables); injection reason string `auto`. Task 4's doctor and Task 6's docs reference `FABLE_AUTO=0`. + +- [ ] **Step 1: Add failing tests** + +In `tests/test_fable_trigger.py`, update the `run` helper to also pop `FABLE_AUTO`: + +```python + env.pop("FABLE_MODE", None) # ditto for the launcher flag + env.pop("FABLE_AUTO", None) # and the auto-heuristic knob +``` + +Append at the end of the file: + +```python +def test_complex_prompt_auto_injects_once(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + prompt = "Сделай рефакторинг hooks/fable-trigger.py и добавь тесты, затем обнови README" + first = run({"prompt": prompt, "session_id": sid}, home) + second = run({"prompt": prompt, "session_id": sid}, home) + assert first, "task-shaped prompt should auto-inject" + ctx = json.loads(first)["hookSpecificOutput"]["additionalContext"] + assert "Fable mode active (auto)" in ctx + assert second == "", "auto path is once per session" + + +def test_fable_auto_opt_out(tmp_path): + home = make_home(tmp_path) + prompt = "Implement a REST endpoint in api/server.py and fix the failing tests" + out = run({"prompt": prompt, "session_id": str(uuid.uuid4())}, home, + extra_env={"FABLE_AUTO": "0"}) + assert out == "", "FABLE_AUTO=0 must disable the heuristic" + + +def test_simple_greeting_stays_silent(tmp_path): + home = make_home(tmp_path) + assert run({"prompt": "привет, как дела?", + "session_id": str(uuid.uuid4())}, home) == "" + assert run({"prompt": "what time is it", + "session_id": str(uuid.uuid4())}, home) == "" +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/test_fable_trigger.py -v` +Expected: `test_complex_prompt_auto_injects_once` FAILS (empty output); the two guard tests PASS already; all pre-existing tests PASS. + +- [ ] **Step 3: Implement the heuristic** + +In `hooks/fable-trigger.py`, after the `HEAVY_EFFORT` constant, add: + +```python +# Auto-activation heuristic: score-based guess that a prompt is a real +# engineering task. >= 2 points = complex. Disable with FABLE_AUTO=0. +TASK_VERBS = re.compile( + r"\b(implement|refactor|migrat\w*|build|creat\w*|add|fix|debug|integrat\w*|" + r"optimi[sz]\w*|rewrit\w*|design|install|set up" + r"|сдела\w*|добав\w*|почин\w*|исправ\w*|реализу\w*|перепиш\w*|настро\w*|" + r"созда\w*|собер\w*|интегрир\w*|оптимизир\w*|мигрир\w*|разработ\w*)", re.I) +MULTISTEP = re.compile(r"\b(затем|потом|после этого|then|steps?)\b|^\s*\d+[.)]\s", + re.I | re.M) +PATHISH = re.compile( + r"\w+\.(py|js|ts|tsx|java|go|rs|c|cpp|h|cs|rb|php|md|json|ya?ml|toml|ps1|sh|zsh)\b" + r"|[/\\][\w.-]+[/\\]") +FENCE_OR_TRACE = re.compile(r"```|Traceback \(most recent call last\)|^\s*File \"", + re.M) + + +def looks_complex(prompt): + score = 0 + if len(prompt) >= 400: + score += 2 + if FENCE_OR_TRACE.search(prompt): + score += 2 + score += min(len(set(m.group(0).lower() for m in TASK_VERBS.finditer(prompt))), 2) + if PATHISH.search(prompt): + score += 1 + if MULTISTEP.search(prompt): + score += 1 + if len([ln for ln in prompt.splitlines() if ln.strip()]) >= 3: + score += 1 + return score >= 2 +``` + +In `main()`, replace the block from `phrase = bool(TRIGGER.search(prompt))` through the final `inject(...)` call with: + +```python + phrase = bool(TRIGGER.search(prompt)) + effort = active_effort(data) + auto = os.environ.get("FABLE_AUTO", "").strip() != "0" and looks_complex(prompt) + heavy = effort in HEAVY_EFFORT or fable_mode() or auto + + if not (phrase or heavy): + return + + # Heavy-only trigger: inject just once per session. + if heavy and not phrase: + if os.path.exists(marker): + return + write_marker(marker) + + if phrase: + why = "phrase" + elif effort in HEAVY_EFFORT: + why = "effort=" + effort + elif fable_mode(): + why = "launcher" + else: + why = "auto" + inject("UserPromptSubmit", why) +``` + +Also update the module docstring's UserPromptSubmit paragraph to mention the auto path: + +``` +UserPromptSubmit A trigger phrase ("use fable" / "fable mode" / "load fable") + always injects (explicit intent; re-say after a compaction). + Heavy effort (payload effort.level, else CLAUDE_EFFORT env), + FABLE_MODE, or a task-shaped prompt (looks_complex heuristic; + disable with FABLE_AUTO=0) injects once per session (marker + file keyed by session_id). +``` + +- [ ] **Step 4: Run the full trigger suite** + +Run: `python -m pytest tests/test_fable_trigger.py -v` +Expected: all 14 tests PASS. + +--- + +### Task 2: Proactive `/fable` skill + +**Files:** +- Modify: `skills/fable/SKILL.md` + +**Interfaces:** +- Consumes: nothing new. Produces: skill description used verbatim by Task 6's README bullet. + +- [ ] **Step 1: Update frontmatter description** + +Replace the `description:` line with: + +```yaml +description: Activate Fable mode - adopt the Fable execution playbook and the Claude Code behavior layer as standing discipline for this session. Use when the user types /fable, says "fable mode", "use fable", "work like fable", or asks for maximum-discipline execution - and PROACTIVELY at the start of any non-trivial engineering task (multi-step implementation, refactor, migration, long debugging session) when the playbook is not already in context. +``` + +- [ ] **Step 2: Add the already-loaded shortcut to the body** + +Insert after the numbered list (before "Scale the heavier machinery"): + +```markdown +If the playbook is already in this context (look for a "Fable mode active" +injection from the hook), skip the reads: confirm activation in one line and +apply `fable-code.md` from context, reading it only if absent. +``` + +- [ ] **Step 3: Verify frontmatter still parses** + +Run: `python -c "import pathlib; t = pathlib.Path('skills/fable/SKILL.md').read_text(encoding='utf-8'); assert t.startswith('---') and 'PROACTIVELY' in t; print('frontmatter ok')"` +Expected: `frontmatter ok` + +--- + +### Task 3: Launcher dispatch — `doctor` and `--ultra` + +**Files:** +- Modify: `shell/fable.zsh` (full replacement) +- Modify: `shell/fable.ps1` (full replacement) + +**Interfaces:** +- Consumes: `~/.claude/hooks/fable-doctor.py` (created in Task 4; the launcher only references the path). +- Produces: `fable doctor [...]` and `fable --ultra|-u [...]` in both shells. + +- [ ] **Step 1: Replace `shell/fable.zsh`** + +```zsh +# Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` +# +# `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior +# layer appended, xhigh effort, FABLE_MODE=1 declared so +# fable-trigger.py injects the playbook at SessionStart. +# `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent +# workflows for substantive tasks (heavy on tokens). +# `fable doctor` Verify the whole install/activation chain mechanically. +# +# install.py copies fable-code.md and fable-doctor.py into ~/.claude for you. +fable() { + if [[ "$1" == "doctor" ]]; then + shift + python3 "$HOME/.claude/hooks/fable-doctor.py" "$@" + return + fi + local -a extra + if [[ "$1" == "--ultra" || "$1" == "-u" ]]; then + shift + extra=(--settings '{"ultracode": true}') + fi + FABLE_MODE=1 claude --model claude-opus-4-8 \ + --append-system-prompt-file "$HOME/.claude/fable-code.md" \ + --effort xhigh "${extra[@]}" "$@" +} +``` + +- [ ] **Step 2: Replace `shell/fable.ps1`** + +```powershell +# Fable mode launcher (PowerShell). Dot-source from your profile, or: +# . C:\path\to\fable-mode\shell\fable.ps1 +# +# `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior +# layer appended, xhigh effort, FABLE_MODE=1 declared so +# fable-trigger.py injects the playbook at SessionStart. +# `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent +# workflows for substantive tasks (heavy on tokens). +# `fable doctor` Verify the whole install/activation chain mechanically. +# +# install.ps1 copies fable-code.md and fable-doctor.py into ~\.claude for you. +function fable { + $rest = @($args) + if ($rest.Count -gt 0 -and $rest[0] -eq 'doctor') { + & python "$HOME\.claude\hooks\fable-doctor.py" @($rest | Select-Object -Skip 1) + return + } + $extra = @() + if ($rest.Count -gt 0 -and ($rest[0] -eq '--ultra' -or $rest[0] -eq '-u')) { + $rest = @($rest | Select-Object -Skip 1) + $extra = @('--settings', '{"ultracode": true}') + } + $env:FABLE_MODE = "1" + try { + claude --model claude-opus-4-8 ` + --append-system-prompt-file "$HOME\.claude\fable-code.md" ` + --effort xhigh @extra @rest + } + finally { + Remove-Item Env:FABLE_MODE -ErrorAction SilentlyContinue + } +} +``` + +- [ ] **Step 3: Syntax-check both** + +Run: `bash -n shell/fable.zsh && echo "zsh ok"` +Expected: `zsh ok` +Run (PowerShell): `. ./shell/fable.ps1; if (Get-Command fable -ErrorAction SilentlyContinue) { 'ps1 ok' } else { exit 1 }` +Expected: `ps1 ok` + +--- + +### Task 4: `hooks/fable-doctor.py` + +**Files:** +- Create: `hooks/fable-doctor.py` +- Test: Create `tests/test_fable_doctor.py` + +**Interfaces:** +- Consumes: the installed layout under `~/.claude` (resolved via `HOME`/`USERPROFILE` at runtime) and `hooks/fable-trigger.py`'s stdin/stdout contract from Task 1. +- Produces: exit 0 = healthy (warnings allowed), exit 1 = hard failure; env knob `FABLE_DOCTOR_SKIP_CLI=1` skips the `claude --version` probe. Task 5 installs/uninstalls this file; Task 3's launchers invoke it. + +- [ ] **Step 1: Write failing tests `tests/test_fable_doctor.py`** + +```python +"""Tests for hooks/fable-doctor.py — the install/activation chain checker.""" +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +DOCTOR = REPO / "hooks" / "fable-doctor.py" + + +def run_doctor(home): + env = dict(os.environ) + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["FABLE_DOCTOR_SKIP_CLI"] = "1" # offline + fast in CI + env.pop("CLAUDE_EFFORT", None) + env.pop("FABLE_MODE", None) + return subprocess.run([sys.executable, str(DOCTOR)], capture_output=True, + text=True, env=env) + + +def fake_install(home): + claude = home / ".claude" + (claude / "hooks").mkdir(parents=True) + (claude / "skills" / "fable").mkdir(parents=True) + (claude / "agents").mkdir(parents=True) + for rel in ("FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md"): + (claude / rel).write_text("PLAYBOOK", encoding="utf-8") + shutil.copy(REPO / "hooks" / "fable-trigger.py", + claude / "hooks" / "fable-trigger.py") + (claude / "hooks" / "test-after-edit.py").write_text("# stub", encoding="utf-8") + (claude / "hooks" / "fable-doctor.py").write_text("# stub", encoding="utf-8") + (claude / "skills" / "fable" / "SKILL.md").write_text("---\nname: fable\n---", + encoding="utf-8") + (claude / "agents" / "grounding-verifier.md").write_text("x", encoding="utf-8") + trigger = str(claude / "hooks" / "fable-trigger.py") + tester = str(claude / "hooks" / "test-after-edit.py") + settings = { + "alwaysThinkingEnabled": True, + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, trigger)}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, trigger)}]}], + "PostToolUse": [{"matcher": "Edit|Write|MultiEdit", + "hooks": [{"type": "command", + "command": '"{}" "{}"'.format(sys.executable, tester)}]}], + }, + } + (claude / "settings.json").write_text(json.dumps(settings), encoding="utf-8") + + +def test_fresh_home_fails_with_named_missing_files(tmp_path): + p = run_doctor(tmp_path) + assert p.returncode == 1 + assert "missing" in p.stdout + assert "FABLE_PLAYBOOK.md" in p.stdout + + +def test_healthy_install_passes_live_fire(tmp_path): + fake_install(tmp_path) + p = run_doctor(tmp_path) + assert "live fire: SessionStart injects" in p.stdout, p.stdout + assert "live fire: UserPromptSubmit phrase injects" in p.stdout, p.stdout + assert p.returncode == 0, p.stdout + + +def test_unregistered_hook_is_a_hard_failure(tmp_path): + fake_install(tmp_path) + claude = tmp_path / ".claude" + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + del s["hooks"]["SessionStart"] + (claude / "settings.json").write_text(json.dumps(s), encoding="utf-8") + p = run_doctor(tmp_path) + assert p.returncode == 1 + assert "not registered for SessionStart" in p.stdout +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/test_fable_doctor.py -v` +Expected: all 3 tests FAIL/ERROR (`hooks/fable-doctor.py` does not exist). + +- [ ] **Step 3: Write `hooks/fable-doctor.py`** + +```python +#!/usr/bin/env python3 +"""fable doctor: verify the Fable mode install/activation chain end to end. + +Run directly or via the `fable doctor` launcher subcommand. Exit code 1 only +on hard failures (missing core files, unparseable/unregistered settings, +live-fire failure); warnings and info lines never fail the run. + +Env knobs: + FABLE_DOCTOR_SKIP_CLI=1 skip the `claude --version` probe (offline/tests) +""" +import glob +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import uuid + +HOME = os.path.expanduser("~") +CLAUDE = os.path.join(HOME, ".claude") + +FAILS = [] +WARNS = [] + + +def ok(msg): + print("[ok] " + msg) + + +def fail(msg): + FAILS.append(msg) + print("[!!] " + msg) + + +def warn(msg): + WARNS.append(msg) + print("[--] " + msg) + + +def check_python(): + if sys.version_info >= (3, 9): + ok("python {}.{}.{}".format(*sys.version_info[:3])) + else: + fail("python >= 3.9 required, running {}.{}".format(*sys.version_info[:2])) + + +CORE_FILES = [ + "FABLE_PLAYBOOK.md", + "fable-code.md", + "fable-system.md", + os.path.join("hooks", "fable-trigger.py"), + os.path.join("hooks", "test-after-edit.py"), + os.path.join("skills", "fable", "SKILL.md"), + os.path.join("agents", "grounding-verifier.md"), +] + + +def check_files(): + for rel in CORE_FILES: + path = os.path.join(CLAUDE, rel) + if os.path.isfile(path): + ok("present: " + rel) + else: + fail("missing: " + path + " (re-run install.py)") + + +def check_settings(): + path = os.path.join(CLAUDE, "settings.json") + if not os.path.isfile(path): + fail("settings.json missing (re-run install.py)") + return + try: + with open(path, encoding="utf-8") as f: + d = json.load(f) + except Exception as e: + fail("settings.json unreadable: {}".format(e)) + return + hooks = d.get("hooks", {}) + for event, needle in (("SessionStart", "fable-trigger.py"), + ("UserPromptSubmit", "fable-trigger.py"), + ("PostToolUse", "test-after-edit.py")): + cmds = [h.get("command", "") for e in hooks.get(event, []) + for h in e.get("hooks", [])] + hit = next((c for c in cmds if needle in c), None) + if not hit: + fail("{} not registered for {} (re-run install.py)".format(needle, event)) + continue + ok("{} registered for {}".format(needle, event)) + m = re.match(r'"([^"]+)"', hit) + interp = m.group(1) if m else hit.split()[0] + if not (os.path.isfile(interp) or shutil.which(interp)): + fail("hook interpreter not found: " + interp) + if d.get("alwaysThinkingEnabled") is not True: + warn("alwaysThinkingEnabled is off - reasoning-density lever missing") + + +def check_claude_cli(): + if os.environ.get("FABLE_DOCTOR_SKIP_CLI") == "1": + warn("claude CLI probe skipped (FABLE_DOCTOR_SKIP_CLI=1)") + return + if not shutil.which("claude"): + warn("claude CLI not on PATH - the fable launcher won't start") + return + try: + out = subprocess.run(["claude", "--version"], capture_output=True, + text=True, timeout=30).stdout.strip() + except Exception as e: + warn("claude --version failed: {}".format(e)) + return + m = re.search(r"(\d+)\.(\d+)\.(\d+)", out) + if not m: + warn("could not parse claude version from: " + out) + return + ver = tuple(int(x) for x in m.groups()) + label = ".".join(map(str, ver)) + if ver >= (2, 1, 199): + ok("claude {} (effort exposed to hooks)".format(label)) + else: + warn("claude {} < 2.1.199: effort not exposed to hooks; " + "phrase/FABLE_MODE/SessionStart paths unaffected".format(label)) + + +def _fire(payload, env_extra): + hook = os.path.join(CLAUDE, "hooks", "fable-trigger.py") + env = dict(os.environ) + env.pop("CLAUDE_EFFORT", None) + env.pop("FABLE_MODE", None) + env.update(env_extra) + p = subprocess.run([sys.executable, hook], input=json.dumps(payload), + capture_output=True, text=True, timeout=30, env=env) + return p.stdout.strip() + + +def check_live_fire(): + if not os.path.isfile(os.path.join(CLAUDE, "hooks", "fable-trigger.py")): + return # already reported by check_files + sid = "doctor-" + uuid.uuid4().hex[:8] + try: + ss = _fire({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, {"FABLE_MODE": "1"}) + ups = _fire({"prompt": "use fable", "session_id": sid + "-b"}, {}) + except Exception as e: + fail("live fire errored: {}".format(e)) + return + finally: + for s in (sid, sid + "-b"): + try: + os.remove(os.path.join(tempfile.gettempdir(), "fable-loaded-" + s)) + except OSError: + pass + for name, out, event in (("SessionStart", ss, "SessionStart"), + ("UserPromptSubmit phrase", ups, "UserPromptSubmit")): + try: + payload = json.loads(out)["hookSpecificOutput"] + assert payload["hookEventName"] == event + assert "Fable mode active" in payload["additionalContext"] + ok("live fire: {} injects".format(name)) + except Exception: + fail("live fire: {} produced no/invalid injection: {!r}".format( + name, out[:80])) + + +def check_evidence(): + files = glob.glob(os.path.join(CLAUDE, "projects", "*", "*.jsonl")) + files.sort(key=os.path.getmtime, reverse=True) + for path in files[:10]: + try: + with open(path, encoding="utf-8", errors="ignore") as f: + for line in f: + if "Fable mode active" in line: + ok("injection seen in a recent session: " + + os.path.basename(path)) + return + except OSError: + continue + warn("no injection found in the 10 most recent transcripts " + "(fine if you haven't used fable yet)") + + +def main(): + print("fable doctor - checking " + CLAUDE) + check_python() + check_files() + check_settings() + check_claude_cli() + check_live_fire() + check_evidence() + print() + if FAILS: + print("RESULT: {} failure(s), {} warning(s) - Fable mode will not work " + "correctly.".format(len(FAILS), len(WARNS))) + sys.exit(1) + print("RESULT: healthy ({} warning(s)).".format(len(WARNS))) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run doctor tests** + +Run: `python -m pytest tests/test_fable_doctor.py -v` +Expected: 3 tests PASS. + +--- + +### Task 5: Install/uninstall wiring for the doctor + +**Files:** +- Modify: `install.py` (hooks copy step) +- Modify: `uninstall.py` (hooks removal step) +- Test: `tests/test_install.py`, `tests/test_uninstall.py` + +**Interfaces:** +- Consumes: `hooks/fable-doctor.py` (Task 4). Produces: `~/.claude/hooks/fable-doctor.py` present after install, absent after uninstall. + +- [ ] **Step 1: Add failing assertions** + +`tests/test_install.py`, in `test_install_copies_everything` after the `test-after-edit.py` assert: + +```python + assert (claude / "hooks" / "fable-doctor.py").is_file() +``` + +`tests/test_uninstall.py`, after the fable-trigger assert: + +```python + assert not (claude / "hooks" / "fable-doctor.py").exists() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/test_install.py tests/test_uninstall.py -v` +Expected: `test_install_copies_everything` FAILS; `test_uninstall_reverses_install` still PASSES (nothing installed → nothing left behind), which is acceptable — the install assert is the driving failure. + +- [ ] **Step 3: Implement** + +`install.py`, in the hooks step after `copy_into("hooks/test-after-edit.py", ...)`: + +```python + copy_into("hooks/fable-doctor.py", os.path.join(CLAUDE, "hooks")) +``` + +`uninstall.py`, in `main()` after the test-after-edit `rm_file`: + +```python + rm_file(os.path.join(CLAUDE, "hooks", "fable-doctor.py")) +``` + +- [ ] **Step 4: Run both test files** + +Run: `python -m pytest tests/test_install.py tests/test_uninstall.py -v` +Expected: all PASS. + +--- + +### Task 6: Playbook orchestration section + docs + +**Files:** +- Modify: `FABLE_PLAYBOOK.md` (append section after Enforcement) +- Modify: `README.md` (bundle bullets) +- Modify: `CHANGELOG.md` (Unreleased → Added) + +**Interfaces:** +- Consumes: `fable --ultra`, `fable doctor`, `FABLE_AUTO=0` from Tasks 1/3/4. + +- [ ] **Step 1: Append to `FABLE_PLAYBOOK.md`** (after the Enforcement section's final bullet, at end of file) + +```markdown + +--- + +## Orchestration — scale the harness to the task + +Fable's discipline is single-context by default; escalate deliberately: + +- **Fan out when the task decomposes.** Independent units (N files to migrate, + M subsystems to map, review dimensions) → parallel subagents, each with a + scoped brief; synthesize in the main context. Never serialize what has no + data dependency (Fix 3, applied to agents). +- **Verify adversarially.** Fan-in results are claims, not facts: route + non-trivial ones through the evidence ledger, and spawn the cold + `grounding-verifier` on the merged result — a subagent never self-approves + its own output (Grounding section above). +- **Plan-gate before long autonomy.** Multi-phase runs get a phased plan and a + live task list first; return to the plan at each phase boundary. +- **Calibrate ruthlessly.** One context that fits the whole task beats any + orchestration. No multi-agent machinery for typo-class work. +- **Mechanical lever:** `fable --ultra` launches with `ultracode` — the harness + auto-runs multi-agent workflows for substantive tasks. Heavy on tokens; the + calibration rule above is the counterweight. +``` + +- [ ] **Step 2: Update `README.md` bundle bullets** + +Replace the hooks bullet: + +```markdown +- **Hooks** — `fable-trigger.py` injects the playbook at `xhigh`/`max`/`ultracode`; `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. +``` + +with: + +```markdown +- **Hooks** — `fable-trigger.py` injects the playbook when the launcher declares the mode, at `xhigh`/`max`/`ultracode` effort, on a trigger phrase, or **by itself when the prompt looks like a real task** (ru+en heuristic; opt out with `FABLE_AUTO=0`); `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. +``` + +Replace the launcher bullet (from the previous rework) with: + +```markdown +- **`fable` launcher** — pins `--model claude-opus-4-8`, appends `fable-code.md`, sets `xhigh` effort, and declares the mode via `FABLE_MODE=1` so the playbook injects at session start on every Claude Code version (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). `fable --ultra` adds ultracode multi-agent orchestration; `fable doctor` verifies the whole install/activation chain in one command. +``` + +- [ ] **Step 3: Update `CHANGELOG.md`** — under `## [Unreleased]` → `### Added`, prepend: + +```markdown +- Auto-activation: `fable-trigger.py` scores prompt complexity (ru+en signals: + task verbs, code fences, file paths, multi-step markers, length) and loads + the playbook by itself for task-shaped prompts, once per session, in any + session — no launcher or phrase needed. Opt out with `FABLE_AUTO=0`. +- `fable --ultra` (alias `-u`): launches with ultracode auto-orchestration; new + "Orchestration" section in the playbook (fan-out, adversarial verification + via `grounding-verifier`, plan-gating, calibration). +- `fable doctor`: one-command diagnosis of the install/activation chain — + files, registered hooks, interpreter paths, Claude Code version, a live-fire + injection test, and transcript evidence of past activations. +- The `/fable` skill triggers proactively at the start of non-trivial tasks. +``` + +- [ ] **Step 4: Full suite** + +Run: `python -m pytest -q` +Expected: all tests PASS (29 + 3 doctor = 32 or more, exact count printed). + +--- + +## Self-Review Notes + +- Spec coverage: §1 → Tasks 1–2, §2 → Tasks 3 + 6, §3 → Tasks 3–5, §4 → Tasks 5–6. No gaps. +- Consistency: `FABLE_AUTO`, `FABLE_DOCTOR_SKIP_CLI`, `fable-doctor.py`, reason string `auto`, + `--ultra`/`-u` used identically across tasks. +- The doctor's `check_evidence` scans only the 10 newest transcripts by mtime — bounded I/O. diff --git a/docs/superpowers/plans/2026-07-03-fable-mode-rework.md b/docs/superpowers/plans/2026-07-03-fable-mode-rework.md new file mode 100644 index 0000000..bacc60c --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-fable-mode-rework.md @@ -0,0 +1,724 @@ +# Fable Mode Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make fable-mode activation fire deterministically on every Claude Code version and replace the consumer-prompt payload with a Claude Code-native Fable behavior layer. + +**Architecture:** The `fable` launcher declares the mode via `FABLE_MODE=1`, pins `--model claude-opus-4-8`, and appends a new `fable-code.md`; `fable-trigger.py` becomes dual-event (SessionStart injects the playbook when the launcher declared the mode; UserPromptSubmit keeps phrase/effort paths and gains a FABLE_MODE fallback); a new `skills/fable/` skill gives explicit `/fable` activation. + +**Tech Stack:** Python ≥ 3.9 stdlib only (hooks, installer, tests via pytest), zsh/bash + PowerShell launchers, Claude Code hooks API (SessionStart, UserPromptSubmit). + +## Global Constraints + +- Python code must run on 3.9+, stdlib only — no pip dependencies (README promise). +- Hooks must never crash the session: exit 0, silent on any error (existing contract). +- All tests must pass on Windows AND POSIX (CI runs both; no `HOME`-only or `/tmp`-literal assumptions). +- **Commits are deferred:** the working tree carries the user's uncommitted WIP on overlapping files (`shell/*`, `uninstall.py`, `scripts/merge_settings.py`, `README.md`, `CHANGELOG.md`, tests). Run the test cycle per task, but do NOT `git commit`; the user slices commits on return. Commit messages, when they happen, carry no AI attribution (user's global rule). +- Spec: `docs/superpowers/specs/2026-07-03-fable-mode-rework-design.md`. + +--- + +### Task 1: Dual-event `fable-trigger.py` + +**Files:** +- Modify: `hooks/fable-trigger.py` (full rewrite below) +- Test: `tests/test_fable_trigger.py` + +**Interfaces:** +- Consumes: hook stdin JSON — `hook_event_name`, `source`, `prompt`, `effort`, `session_id`; env `FABLE_MODE`, `CLAUDE_EFFORT`. +- Produces: stdout JSON `{"hookSpecificOutput": {"hookEventName": "", "additionalContext": "..."}}`; marker file `fable-loaded-` in `tempfile.gettempdir()`. Tasks 4–5 register/deregister this script for both events. + +- [ ] **Step 1: Extend the test helper and add failing tests** + +Replace `tests/test_fable_trigger.py` lines 13–21 (the `run` helper) with: + +```python +def run(stdin_obj, home, extra_env=None): + env = dict(os.environ) + env["HOME"] = str(home) # expanduser on POSIX + env["USERPROFILE"] = str(home) # expanduser on Windows + env.pop("CLAUDE_EFFORT", None) # effort is driven by the payload, not the dev's session + env.pop("FABLE_MODE", None) # ditto for the launcher flag + if extra_env: + env.update(extra_env) + p = subprocess.run([sys.executable, str(HOOK)], + input=json.dumps(stdin_obj), text=True, + capture_output=True, env=env) + return p.stdout.strip() +``` + +Append these tests at the end of the file: + +```python +def test_sessionstart_with_fable_mode_injects(tmp_path): + home = make_home(tmp_path) + out = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": str(uuid.uuid4())}, home, extra_env={"FABLE_MODE": "1"}) + assert out, "SessionStart under FABLE_MODE=1 should inject" + payload = json.loads(out)["hookSpecificOutput"] + assert payload["hookEventName"] == "SessionStart" + assert "PLAYBOOK_MARKER_42" in payload["additionalContext"] + + +def test_sessionstart_without_fable_mode_is_silent(tmp_path): + home = make_home(tmp_path) + out = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": str(uuid.uuid4())}, home) + assert out == "" + + +def test_sessionstart_resume_respects_marker_but_compact_reinjects(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + first = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, home, extra_env=env) + resume = run({"hook_event_name": "SessionStart", "source": "resume", + "session_id": sid}, home, extra_env=env) + compact = run({"hook_event_name": "SessionStart", "source": "compact", + "session_id": sid}, home, extra_env=env) + assert first, "startup should inject" + assert resume == "", "resume with marker should stay silent" + assert compact, "compact wiped context, must re-inject" + + +def test_fable_mode_env_injects_once_on_userpromptsubmit(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + first = run({"prompt": "hi", "session_id": sid}, home, extra_env=env) + second = run({"prompt": "hi again", "session_id": sid}, home, extra_env=env) + assert first, "FABLE_MODE should inject on the first prompt" + assert second == "", "and stay silent for the rest of the session" + + +def test_sessionstart_marker_suppresses_userpromptsubmit_repeat(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + env = {"FABLE_MODE": "1"} + ss = run({"hook_event_name": "SessionStart", "source": "startup", + "session_id": sid}, home, extra_env=env) + ups = run({"prompt": "hello", "session_id": sid}, home, extra_env=env) + assert ss, "SessionStart injects" + assert ups == "", "UserPromptSubmit must not double-inject the same session" + + +def test_effort_level_dict_payload_injects(tmp_path): + home = make_home(tmp_path) + out = run({"prompt": "hi", "effort": {"level": "xhigh"}, + "session_id": str(uuid.uuid4())}, home) + assert out, "effort.level dict form (documented payload) should inject" +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `python -m pytest tests/test_fable_trigger.py -v` +Expected: the 5 pre-existing tests PASS; `test_sessionstart_with_fable_mode_injects`, `test_sessionstart_resume_respects_marker_but_compact_reinjects`, `test_fable_mode_env_injects_once_on_userpromptsubmit`, `test_sessionstart_marker_suppresses_userpromptsubmit_repeat` FAIL (empty output — old script has no SessionStart branch and ignores FABLE_MODE). `test_sessionstart_without_fable_mode_is_silent` and `test_effort_level_dict_payload_injects` may already pass — that's fine. + +- [ ] **Step 3: Rewrite `hooks/fable-trigger.py`** + +Full replacement content: + +```python +#!/usr/bin/env python3 +"""Dual-event hook: load the Fable execution playbook when the mode is active. + +SessionStart When the `fable` launcher declared the mode (FABLE_MODE=1): + sources startup/clear/compact always inject (fresh or wiped + context); resume injects only if this session has no marker + yet. This path works on every Claude Code version — it does + not depend on the harness exposing effort to hooks. +UserPromptSubmit A trigger phrase ("use fable" / "fable mode" / "load fable") + always injects (explicit intent; re-say after a compaction). + Heavy effort (payload effort.level, else CLAUDE_EFFORT env) + or FABLE_MODE injects once per session (marker file keyed by + session_id) so the ~12 KB playbook isn't re-sent every prompt. + +No trigger -> prints nothing -> the playbook costs zero tokens by default. +""" +import sys +import json +import re +import os +import tempfile + +PLAYBOOK = os.path.expanduser(os.path.join("~", ".claude", "FABLE_PLAYBOOK.md")) +TRIGGER = re.compile(r"\b(use fable|fable mode|load fable)\b", re.I) +HEAVY_EFFORT = {"xhigh", "max", "ultracode"} + + +def active_effort(data): + """Effort from the hook JSON (effort.level or effort string), else env.""" + eff = data.get("effort") + if isinstance(eff, dict): + eff = eff.get("level") + if not eff: + eff = os.environ.get("CLAUDE_EFFORT", "") + return str(eff).strip().lower() + + +def fable_mode(): + return os.environ.get("FABLE_MODE", "").strip() == "1" + + +def marker_path(data): + sid = str(data.get("session_id") or "nosession") + sid = re.sub(r"[^A-Za-z0-9_-]", "_", sid) + return os.path.join(tempfile.gettempdir(), "fable-loaded-" + sid) + + +def write_marker(path): + try: + open(path, "w").close() + except Exception: + pass # marker is best-effort; worst case is one duplicate injection + + +def inject(event, why): + try: + with open(PLAYBOOK, encoding="utf-8") as f: + body = f.read() + except Exception: + return # playbook missing/unreadable: never block the session + context = ("Fable mode active ({}). Adopt the execution playbook below as " + "standing discipline for the rest of this session:\n\n".format(why) + + body) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context, + } + })) + + +def main(): + try: + data = json.load(sys.stdin) + except Exception: + return # malformed input: never block the prompt + + event = str(data.get("hook_event_name") or "UserPromptSubmit") + marker = marker_path(data) + + if event == "SessionStart": + if not fable_mode(): + return + source = str(data.get("source") or "startup").lower() + if source == "resume" and os.path.exists(marker): + return # context usually survives a resume; don't double-inject + write_marker(marker) + inject("SessionStart", "launcher") + return + + prompt = data.get("prompt", "") or "" + phrase = bool(TRIGGER.search(prompt)) + effort = active_effort(data) + heavy = effort in HEAVY_EFFORT or fable_mode() + + if not (phrase or heavy): + return + + # Heavy-only trigger: inject just once per session. + if heavy and not phrase: + if os.path.exists(marker): + return + write_marker(marker) + + if phrase: + why = "phrase" + elif effort in HEAVY_EFFORT: + why = "effort=" + effort + else: + why = "launcher" + inject("UserPromptSubmit", why) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run the full test file** + +Run: `python -m pytest tests/test_fable_trigger.py -v` +Expected: all 11 tests PASS. + +- [ ] **Step 5: Manual smoke check (Windows host)** + +Run: `echo {"hook_event_name":"SessionStart","source":"startup","session_id":"smoke-1"} | python hooks/fable-trigger.py` with `FABLE_MODE=1` set, from the repo root, HOME untouched (real playbook installed). +Expected: JSON with `"hookEventName": "SessionStart"` and the real playbook text. Delete the `fable-loaded-smoke-1` marker from `%TEMP%` afterwards. + +--- + +### Task 2: `fable-code.md` + `skills/fable/SKILL.md` + +**Files:** +- Create: `fable-code.md` (repo root, sibling of `fable-system.md`) +- Create: `skills/fable/SKILL.md` + +**Interfaces:** +- Produces: `fable-code.md` consumed by the launchers (Task 3, `--append-system-prompt-file`) and copied by `install.py` (Task 4); `skills/fable/` is picked up automatically by `install.py`'s existing skills loop and by `uninstall.py`'s `bundled_skill_names()`. + +- [ ] **Step 1: Write `fable-code.md`** + +Full content: + +```markdown +# Fable — Claude Code behavior layer + +Provenance: an original distillation of first-party Claude Fable 5 behavior +observed in Claude Code (2026-07). Not the leaked consumer prompt — that file +(`fable-system.md`) describes a chat tab; this one describes how Fable operates +in a terminal harness. Adopt everything below as standing discipline. + +## The final message is the deliverable + +Text between tool calls may never be shown; everything the user needs from a +turn — answers, findings, conclusions — must be in the last message, with no +tool calls after it. If something important surfaced mid-turn, restate it there. +Lead with the outcome: the first sentence answers "what happened" or "what did +you find"; supporting detail and reasoning come after, for readers who want them. + +## Readable beats concise + +Shorten by selecting what matters, not by compressing prose into fragments. +Complete sentences; technical terms spelled out; no arrow chains ("A → B → +fails"); no shorthand or codenames the reader must reverse-engineer; explain in +place instead of referencing labels or numbering invented earlier in the turn. +If the reader has to reread or ask a follow-up, the brevity saved nothing. +Simple questions get plain prose — no headers, no sections. Tables only for +short enumerable facts, explained in surrounding prose, not in the cells. + +## Working rhythm + +One sentence on intent before the first tool call. Surface load-bearing +findings and direction changes the moment they happen — one line each. +Otherwise, no narration between tool calls: no "Let me…", no result recaps, no +progress theater. Several tool calls in a row with no prose between them is +correct, not rude. + +## Tool discipline + +Batch independent tool calls in a single block; keep strict sequencing only +where a step consumes the previous result. Prefer structured tools (Grep, Glob, +Read) over shell pipelines; reference code as `file:line`. Read the exact +region you are about to edit, in this session, immediately before editing — +and your own successful edit invalidates your last read of that file. Absolute +paths, not `cd`. + +## Code and comments + +Write code that reads like the surrounding code — match its comment density, +naming, and idiom. A comment states only a constraint the code cannot show. +Never write comments that narrate the change, justify it to a reviewer, or say +where it came from; that is noise the moment the change lands. + +## Autonomy, honesty, and stopping + +Proceed without asking on reversible actions that follow from the request. +Stop and ask only for destructive actions, outward-facing effects (sending, +publishing), or genuine scope changes. Before deleting or overwriting, look at +the target; if it contradicts its description or you didn't create it, surface +that instead of proceeding. Report outcomes faithfully: failing tests are shown +with their output, skipped steps are named as skipped, and "done" is said only +after verification — an unverified edit is an untrue "done". + +When the user is describing a problem or thinking out loud, the deliverable is +your assessment: report findings and stop; don't apply a fix until asked. + +## The end-of-turn rule + +Before ending a turn, check the last paragraph. If it is a plan, a list of next +steps, a question a tool could answer, or a promise about work not yet done +("I'll…"), do that work now instead of ending the turn. End only when the task +is complete or blocked on input only the user can provide. +``` + +- [ ] **Step 2: Write `skills/fable/SKILL.md`** + +Full content: + +```markdown +--- +name: fable +description: Activate Fable mode - adopt the Fable execution playbook and the Claude Code behavior layer as standing discipline for this session. Use when the user types /fable, says "fable mode", "use fable", "work like fable", or asks for maximum-discipline execution. +--- + +# Fable mode + +Activate the full Fable discipline for the rest of this session: + +1. Read `~/.claude/FABLE_PLAYBOOK.md` (execution discipline: reason before + acting, observe-then-decide, verify every edit, communication floor, + grounding protocol). If the file is missing, read `FABLE_PLAYBOOK.md` from + this skill's repository instead. +2. Read `~/.claude/fable-code.md` (the Claude Code behavior layer: final-message + contract, readable-over-concise, tool discipline, autonomy and honesty + rules). Same fallback. +3. Adopt both as standing discipline — they govern every subsequent turn of + this session, not just the next reply. +4. Confirm activation in one line ("Fable mode active — playbook and behavior + layer loaded."), then continue with the user's task. Do not summarize the + documents back. + +Scale the heavier machinery to the task: the evidence ledger and the +`grounding-verifier` agent are for non-trivial or hard-to-reverse work, not for +typo fixes (the playbook's own calibration rules apply). +``` + +- [ ] **Step 3: Sanity-check formats** + +Run: `python -c "import pathlib; t = pathlib.Path('skills/fable/SKILL.md').read_text(encoding='utf-8'); assert t.startswith('---') and 'name: fable' in t and 'description:' in t; print('frontmatter ok')"` +Expected: `frontmatter ok` + +--- + +### Task 3: Launchers pin the model and declare the mode + +**Files:** +- Modify: `shell/fable.zsh` (full replacement) +- Modify: `shell/fable.ps1` (full replacement) + +**Interfaces:** +- Consumes: `~/.claude/fable-code.md` (Task 2, copied by Task 4's installer). +- Produces: env `FABLE_MODE=1` for the `claude` process tree (consumed by `fable-trigger.py`, Task 1). + +- [ ] **Step 1: Replace `shell/fable.zsh`** + +```zsh +# Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` +# +# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior +# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so +# fable-trigger.py injects the execution playbook at SessionStart — reliable on +# every Claude Code version, not only those that expose effort to hooks. +# +# install.py copies fable-code.md into ~/.claude for you. +# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' +fable() { + FABLE_MODE=1 claude --model claude-opus-4-8 \ + --append-system-prompt-file "$HOME/.claude/fable-code.md" \ + --effort xhigh "$@" +} +``` + +- [ ] **Step 2: Replace `shell/fable.ps1`** + +```powershell +# Fable mode launcher (PowerShell). Dot-source from your profile, or: +# . C:\path\to\fable-mode\shell\fable.ps1 +# +# Launches Claude Code pinned to Opus 4.8 with the Fable Claude-Code behavior +# layer appended and xhigh effort, and declares the mode via FABLE_MODE=1 so +# fable-trigger.py injects the execution playbook at SessionStart — reliable on +# every Claude Code version, not only those that expose effort to hooks. +# +# install.ps1 copies fable-code.md into ~\.claude for you. +# Want multi-agent auto-orchestration too? Add: --settings '{"ultracode": true}' +function fable { + $env:FABLE_MODE = "1" + try { + claude --model claude-opus-4-8 ` + --append-system-prompt-file "$HOME\.claude\fable-code.md" ` + --effort xhigh @args + } + finally { + Remove-Item Env:FABLE_MODE -ErrorAction SilentlyContinue + } +} +``` + +- [ ] **Step 3: Syntax-check both** + +Run: `bash -n shell/fable.zsh` +Expected: no output, exit 0. +Run: `pwsh -NoProfile -Command ". ./shell/fable.ps1; if (Get-Command fable -ErrorAction SilentlyContinue) { 'ps1 ok' } else { exit 1 }"` +Expected: `ps1 ok` + +--- + +### Task 4: Installer wiring (merge_settings, fragment, install.py, install tests) + +**Files:** +- Modify: `scripts/merge_settings.py:44-50` (add SessionStart ensure) +- Modify: `settings.fragment.json` +- Modify: `install.py:98-102` (copy fable-code.md) +- Test: `tests/test_install.py` + +**Interfaces:** +- Consumes: `fable-code.md`, `skills/fable/` (Task 2 — the skills loop copies any `skills/*` dir automatically). +- Produces: `~/.claude/settings.json` with a `SessionStart` entry whose command contains `fable-trigger.py`; `~/.claude/fable-code.md`; `~/.claude/skills/fable/SKILL.md`. Task 5's uninstaller must reverse exactly these. + +- [ ] **Step 1: Add failing assertions to `tests/test_install.py`** + +In `test_install_copies_everything`, after the existing `assert (claude / "fable-system.md").is_file()` line, add: + +```python + assert (claude / "fable-code.md").is_file() + assert (claude / "skills" / "fable" / "SKILL.md").is_file() +``` + +and after the existing `cmd = ...` / `assert sys.executable in cmd` lines, add: + +```python + ss_cmd = s["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "fable-trigger.py" in ss_cmd +``` + +In `test_install_is_idempotent`, after `assert len(s["hooks"]["PostToolUse"]) == 1`, add: + +```python + assert len(s["hooks"]["SessionStart"]) == 1 +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/test_install.py -v` +Expected: both modified tests FAIL (`fable-code.md` missing, `KeyError: 'SessionStart'`). + +- [ ] **Step 3: Implement** + +`scripts/merge_settings.py` — after the existing `ensure("UserPromptSubmit", ...)` call, add: + +```python + ensure("SessionStart", + {"hooks": [{"type": "command", "command": cmd("fable-trigger.py")}]}, + "fable-trigger.py") +``` + +`settings.fragment.json` — full replacement: + +```json +{ + "_comment": "Reference only. install.py merges these into ~/.claude/settings.json (with a backup) and writes ABSOLUTE interpreter + script paths so the hooks work without $HOME/python3 resolution. If editing by hand, replace 'python3' with your interpreter (e.g. 'python' on Windows) and $HOME with the full path to your home dir.", + "alwaysThinkingEnabled": true, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "python3 $HOME/.claude/hooks/fable-trigger.py" } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "python3 $HOME/.claude/hooks/fable-trigger.py" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { "type": "command", "command": "python3 $HOME/.claude/hooks/test-after-edit.py" } + ] + } + ] + } +} +``` + +`install.py` — after the `print("-> fable system prompt")` / `copy_into("fable-system.md", CLAUDE)` pair, add: + +```python + print("-> fable behavior layer (Claude Code)") + copy_into("fable-code.md", CLAUDE) +``` + +- [ ] **Step 4: Run install tests** + +Run: `python -m pytest tests/test_install.py -v` +Expected: all tests PASS (3 tests). + +--- + +### Task 5: Uninstaller wiring + round-trip test + +**Files:** +- Modify: `uninstall.py:106,133-136` +- Test: Create `tests/test_uninstall.py` + +**Interfaces:** +- Consumes: the installed layout produced by `install.py` (Task 4). +- Produces: clean `~/.claude` — no `fable-code.md`, no `skills/fable/`, no `SessionStart`/`UserPromptSubmit`/`PostToolUse` entries referencing fable scripts; foreign hooks preserved. + +- [ ] **Step 1: Write failing round-trip test `tests/test_uninstall.py`** + +```python +"""Round-trip test: install into a sandbox home, uninstall, verify clean state.""" +import importlib.util +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, REPO / filename) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def sandbox(mod, tmp_path, windows=True): + mod.HOME = str(tmp_path) + mod.CLAUDE = str(tmp_path / ".claude") + mod.IS_WINDOWS = windows + mod.powershell_profile_path = lambda: str(tmp_path / "profile.ps1") + return mod + + +def test_uninstall_reverses_install(tmp_path): + inst = sandbox(load("fable_install", "install.py"), tmp_path) + uninst = sandbox(load("fable_uninstall", "uninstall.py"), tmp_path) + inst.main() + + claude = tmp_path / ".claude" + # plant a foreign hook that must survive + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + s["hooks"]["SessionStart"].append( + {"hooks": [{"type": "command", "command": "node keepme.cjs"}]}) + (claude / "settings.json").write_text(json.dumps(s, indent=2), encoding="utf-8") + + uninst.main() + + assert not (claude / "FABLE_PLAYBOOK.md").exists() + assert not (claude / "fable-system.md").exists() + assert not (claude / "fable-code.md").exists() + assert not (claude / "hooks" / "fable-trigger.py").exists() + assert not (claude / "skills" / "fable").exists() + assert not (claude / "skills" / "ground").exists() + assert "fable.ps1" not in (tmp_path / "profile.ps1").read_text(encoding="utf-8") + + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + ss = s["hooks"]["SessionStart"] + assert len(ss) == 1 and "keepme.cjs" in ss[0]["hooks"][0]["command"] + assert "UserPromptSubmit" not in s["hooks"] + assert "PostToolUse" not in s["hooks"] + assert s["alwaysThinkingEnabled"] is True +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m pytest tests/test_uninstall.py -v` +Expected: FAIL — `fable-code.md` still present and the fable `SessionStart` entry survives (uninstaller doesn't know either yet). + +- [ ] **Step 3: Implement in `uninstall.py`** + +In `clean_settings()`, change the event loop line + +```python + for event in ("UserPromptSubmit", "PostToolUse"): +``` + +to + +```python + for event in ("SessionStart", "UserPromptSubmit", "PostToolUse"): +``` + +In `main()`, after `rm_file(os.path.join(CLAUDE, "fable-system.md"))`, add: + +```python + rm_file(os.path.join(CLAUDE, "fable-code.md")) +``` + +- [ ] **Step 4: Run the round-trip test** + +Run: `python -m pytest tests/test_uninstall.py -v` +Expected: PASS. + +--- + +### Task 6: Playbook amendment + docs + +**Files:** +- Modify: `FABLE_PLAYBOOK.md` (add one subsection at the end of "How Fable talks") +- Modify: `README.md` (bundle list, launcher description) +- Modify: `CHANGELOG.md` (Unreleased entries) + +**Interfaces:** +- Consumes: terminology from Tasks 1–3 (`FABLE_MODE`, SessionStart injection, `fable-code.md`). + +- [ ] **Step 1: Amend `FABLE_PLAYBOOK.md`** + +Insert immediately before the `## Grounding — prove it before you call it done` heading: + +```markdown +### The Claude Code layer (first-party, 2026-07) + +Observed first-party Fable 5 behavior in Claude Code adds three corrections to +the voice layer above — adopt them with the same weight: + +- **The final message is the deliverable.** Mid-turn text may never be shown; + answers, findings, and conclusions must all appear in the turn's last + message, outcome first. A perfect investigation with a buried conclusion + reads as no conclusion. +- **Readable beats concise.** Shorten by selecting what matters, not by + compressing prose into fragments, arrow chains ("A → B → fails"), or + shorthand the reader must reverse-engineer. Complete sentences; explain in + place. If the reader must reread or ask a follow-up, the brevity saved + nothing. +- **Assessment mode.** When the user is describing a problem or thinking out + loud rather than requesting a change, the deliverable is the assessment: + report findings and stop; don't apply the fix until asked. +``` + +- [ ] **Step 2: Update `README.md`** + +In "What's in the bundle", replace the launcher bullet + +```markdown +- **`fable` launcher** — Opus 4.8 + the prompt + `xhigh` effort (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Swap `--effort xhigh` for `--settings '{"ultracode": true}'` in the launcher. +``` + +with + +```markdown +- **`fable` launcher** — pins `--model claude-opus-4-8`, appends `fable-code.md`, sets `xhigh` effort, and declares the mode via `FABLE_MODE=1` so the playbook injects at session start on every Claude Code version (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). Want multi-agent auto-orchestration on top? Add `--settings '{"ultracode": true}'`. +``` + +and add two bullets after the `fable-system.md` bullet: + +```markdown +- **`fable-code.md`** — an original Claude Code-native distillation of Fable's terminal behavior (final-message contract, readable-over-concise, tool discipline, autonomy rules). This is what the launcher actually appends; the consumer prompt above stays bundled for reference. +- **`/fable` skill** — explicit activation: reads the playbook + behavior layer and adopts both mid-session, no launcher required. +``` + +- [ ] **Step 3: Update `CHANGELOG.md`** + +Under `## [Unreleased]` → `### Fixed`, add as the first bullet: + +```markdown +- The playbook injector never actually fired: the effort path needs `effort` in + the hook payload or `CLAUDE_EFFORT` in the hook environment, which Claude Code + ≤ 2.1.198 did not provide, and the trigger phrases were undiscoverable. The + launcher now declares the mode via `FABLE_MODE=1` and `fable-trigger.py` + became dual-event — SessionStart injection (version-independent) plus the old + phrase/effort paths and a `FABLE_MODE` fallback on UserPromptSubmit. +``` + +Under `### Added`, add: + +```markdown +- `fable-code.md`: an original Claude Code-native Fable behavior layer; the + launcher appends it instead of the 1,600-line consumer prompt (which stays + bundled for reference). +- `/fable` skill for explicit mid-session activation. +- The launcher pins `--model claude-opus-4-8`, making the README's "runs on + Opus 4.8" promise true regardless of the user's default model. +- Round-trip uninstall test (`tests/test_uninstall.py`). +``` + +- [ ] **Step 4: Full suite + smoke** + +Run: `python -m pytest -q` +Expected: all tests PASS (existing 4 files + new uninstall file; the WIP `test_test_after_edit.py` suite must stay green too). + +--- + +## Self-Review Notes + +- Spec coverage: §1→Task 3, §2→Task 1, §3→Task 2, §4→Task 2, §5→Task 6, §6→Tasks 4–6. No gaps. +- `install.py` needs no skills-loop change for `skills/fable/` (existing loop copies every dir); `uninstall.py` needs no change for it either (`bundled_skill_names()` enumerates the repo). Verified against current sources. +- Type/name consistency: `FABLE_MODE`, `fable-code.md`, `fable-loaded-` markers, `ensure("SessionStart", ...)` used identically across tasks. diff --git a/docs/superpowers/specs/2026-07-03-fable-auto-orchestration-design.md b/docs/superpowers/specs/2026-07-03-fable-auto-orchestration-design.md new file mode 100644 index 0000000..a56aaf6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-fable-auto-orchestration-design.md @@ -0,0 +1,114 @@ +# Fable mode: auto-activation, orchestration, doctor + +**Date:** 2026-07-03 · **Follows:** `2026-07-03-fable-mode-rework-design.md` (implemented earlier today) · **Status:** draft, awaiting user review + +## Problem + +After the activation rework, Fable mode fires reliably — but only when the user launches via +`fable`, types a trigger phrase, or runs at xhigh effort. A plain `claude` session given a complex +task gets no discipline. Orchestration (ultracode) is documented only as a comment. And the class +of silent failure found this morning (installed but never firing) had no self-diagnosis tool. + +## Goals + +1. **Auto-activation:** a complex task activates Fable mode by itself, in any session. +2. **Orchestration:** one-flag access to ultracode plus playbook guidance on when/how to fan out. +3. **`fable doctor`:** one command that verifies the whole install/activation chain mechanically. + +## Design + +### 1. Complexity auto-trigger (hybrid: deterministic + model-side) + +**Deterministic half — `hooks/fable-trigger.py`.** New pure function `looks_complex(prompt)`, +score-based (complex when score ≥ 2): + +| Signal | Points | +|---|---| +| length ≥ 400 chars | 2 | +| fenced code block (```) or traceback marker | 2 | +| task verb, ru+en stems (сдела/добав/почин/исправ/реализу/перепиш/настро/созда/собер/интегрир/оптимизир/мигрир/разработ; implement/refactor/migrate/build/create/add/fix/debug/integrate/optimize/rewrite/design/install) | 1 | +| file path or extension (`\w+\.(py|js|ts|…)`, `/` or `\` path shapes) | 1 | +| multi-step marker (затем/потом/после этого/then/steps/numbered list) | 1 | +| ≥ 3 non-empty lines | 1 | + +Wired into the UserPromptSubmit branch as a third "heavy" source (alongside effort and +FABLE_MODE): injects the playbook once per session (same marker), reason `auto`. Opt-out: +`FABLE_AUTO=0` disables the heuristic entirely; phrase/effort/FABLE_MODE paths are unaffected. +The heuristic never *blocks* anything — worst case is one 12 KB injection in a session that +didn't need it. + +**Model-side half — `skills/fable/SKILL.md`.** Description gains a proactive clause: use at the +start of any non-trivial engineering task (multi-step implementation, refactor, migration, long +debugging) when the playbook is not already in context. Body gains: if the playbook content is +already present in context (hook injected it), skip the file reads and just confirm activation. + +### 2. Orchestration + +**Launcher flag.** `fable --ultra` (alias `-u`) inserts `--settings '{"ultracode": true}'` before +the passthrough args in both `fable.zsh` and `fable.ps1` (parsed only as the first argument). +Everything else about the launch (model pin, `fable-code.md`, xhigh, `FABLE_MODE=1`) is unchanged. + +**Playbook section.** New short section "Orchestration — scale the harness to the task" appended +after Enforcement: when a task decomposes into independent units, fan out subagents in parallel +and verify adversarially (the cold `grounding-verifier` is the built-in verifier); plan-gate +before long autonomous runs; never bring multi-agent machinery to a task one context handles — +the calibration rule already in "Carried over" applies with force. Mentions `fable --ultra` as +the mechanical lever for auto-workflows. + +### 3. `fable doctor` + +New `hooks/fable-doctor.py` (stdlib-only, Python ≥ 3.9), installed next to the hooks it +diagnoses; invoked via the `fable doctor` launcher subcommand (first-argument dispatch, both +shells) or directly. Checks, each printed as `[ok]` / `[!!]` (failure) / `[--]` (warning/info): + +1. Python version ≥ 3.9. +2. Presence of `~/.claude/`: FABLE_PLAYBOOK.md, fable-code.md, fable-system.md, + hooks/fable-trigger.py, hooks/test-after-edit.py, skills/fable/SKILL.md, + agents/grounding-verifier.md. +3. `settings.json` parses; SessionStart + UserPromptSubmit reference fable-trigger.py, PostToolUse + references test-after-edit.py; the interpreter path inside each hook command exists. +4. `claude` CLI on PATH; version parsed from `claude --version` — warn (not fail) below 2.1.199 + ("effort not exposed to hooks; SessionStart path unaffected"). +5. **Live fire:** runs the *installed* fable-trigger.py twice — SessionStart payload with + FABLE_MODE=1, and UserPromptSubmit with a trigger phrase — asserting JSON output with the + right hookEventName; cleans up its markers. +6. Injection evidence: scans the ~10 most recent transcripts under `~/.claude/projects/` for + "Fable mode active" (info only: last-seen timestamp or "never seen"). +7. `alwaysThinkingEnabled` true (warning if not). + +Exit code 1 only on hard failures (missing core files, unparseable/unregistered settings, live-fire +failure); warnings and info don't fail the run. `FABLE_DOCTOR_SKIP_CLI=1` skips the `claude +--version` probe (used by tests to stay fast and offline). + +### 4. Install / uninstall / tests / docs + +- `install.py`: `copy_into("hooks/fable-doctor.py", ...)` in the hooks step. +- `uninstall.py`: `rm_file` for fable-doctor.py. +- Tests: `looks_complex` end-to-end through the hook (ru prompt with verb+multistep injects once, + reason `auto`; `FABLE_AUTO=0` silences it; short greeting stays silent; phrase path unaffected); + doctor against a sandbox home (fresh home → exit 1 with named missing files; faked full install + → exit 0 with live-fire pass); install/uninstall round-trip includes doctor file; launcher + syntax checks re-run. +- README: bundle bullets for auto-activation, `--ultra`, `fable doctor`; CHANGELOG Added entries. + +## Alternatives considered + +- **LLM-scored complexity** (hook calls a model): non-deterministic, adds latency and cost to + every prompt — rejected; the score heuristic is testable and free. +- **Doctor as a separate repo script** (`scripts/`): rejected — it must ship to the machine it + diagnoses; ~/.claude/hooks is already the installed-tooling location, and the launcher knows it. +- **Always-inject in every session:** simplest, but burns 12 KB on trivial sessions — rejected; + scoring keeps the zero-cost-by-default promise. + +## Out of scope + +- Changing test-after-edit.py (user WIP). +- Auto-update checks, statusline integration, telemetry of any kind. + +## Decisions + +1. **User-confirmed (2026-07-03):** hybrid auto-activation (heuristic hook + proactive skill + description) and the full feature set: auto-activation + orchestration + doctor. +2. Taken on the user's behalf: heuristic thresholds (score ≥ 2, weights above) — tuned for + "obvious tasks trigger, greetings don't"; easy to retune, covered by tests. +3. Taken on the user's behalf: doctor lives in `hooks/` (ships with install), not `scripts/`. diff --git a/docs/superpowers/specs/2026-07-03-fable-mode-rework-design.md b/docs/superpowers/specs/2026-07-03-fable-mode-rework-design.md new file mode 100644 index 0000000..e72f6a4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-fable-mode-rework-design.md @@ -0,0 +1,142 @@ +# Fable mode rework — reliable activation + Claude Code fidelity + +**Date:** 2026-07-03 · **Branch:** cross-platform-install (new work should move to its own branch) · **Status:** draft, awaiting user review + +## Problem + +The core of fable-mode — playbook injection via `fable-trigger.py` — has **never fired once** for a real +user session. Evidence gathered on 2026-07-03: + +- Zero `fable-loaded-*` session markers in `%TEMP%` before today; zero "Fable mode active" injections in + any transcript under `~/.claude/projects/` (the only mentions are today's manual probes). +- The effort path requires `effort.level` in the hook payload or `CLAUDE_EFFORT` in the hook's + environment. On the Claude Code versions the user actually ran (≤ 2.1.198), neither reached the hook; + today's headless probes fired only because `CLAUDE_EFFORT=xhigh` leaked in from the investigating + session's tool environment. +- The phrase path ("use fable" / "fable mode" / "load fable") was never typed. +- By contrast, `test-after-edit.py` (PostToolUse) works and has worked — 36 sessions with its output + since June 29, plus a live green run during this investigation (`pytest -q`, 4.6s). + +Secondary problems: + +- `shell/fable.{zsh,ps1}` does not pin a model. The README promises "Opus 4.8 + Fable prompt"; with the + user's default model now `claude-fable-5`, `fable` would launch the real Fable 5 with the leaked + *consumer* prompt appended on top — double-prompting, not emulation. +- The appended `fable-system.md` is the claude.ai consumer prompt (~1,600 lines): artifacts/storage + APIs, MCP app suggestions, copyright rules — dead weight and occasional contradictions in a terminal. + The README itself admits this ("trim to the claude_behavior section"). +- There is no actual *skill*: activation is a regex in a hook, undiscoverable and invisible. + +## Goal + +"Реально работал как fable": (1) activation that fires deterministically, on every supported Claude +Code version, visibly; (2) emulation content that matches how Fable actually behaves *in Claude Code*, +not in a chat tab. + +## Design (chosen approach: hybrid activation) + +### 1. Launcher pins the model and declares the mode + +`shell/fable.zsh`: + +```zsh +fable() { + FABLE_MODE=1 claude --model claude-opus-4-8 \ + --append-system-prompt-file "$HOME/.claude/fable-code.md" --effort xhigh "$@" +} +``` + +`shell/fable.ps1`: same flags; sets `$env:FABLE_MODE = "1"` before `claude` and removes it in +`finally`. Both keep `@args`/`"$@"` passthrough. `--model claude-opus-4-8` makes the README's promise +true regardless of the user's default model. The appended file becomes the new `fable-code.md` +(see §3); the consumer `fable-system.md` stays in the repo for reference and claude.ai use. + +### 2. `fable-trigger.py` becomes dual-event + +Registered for **SessionStart** and **UserPromptSubmit** (settings.fragment.json + merge_settings.py); +the script branches on `hook_event_name`: + +- **SessionStart** (`FABLE_MODE=1` in env): inject FABLE_PLAYBOOK.md as `additionalContext` — the same + mechanism the superpowers plugin uses, proven to work on every version in evidence. Sources + `startup`/`clear`/`compact` always inject and (re)write the marker — after a compaction the earlier + injection is gone from context, so the marker must not suppress it. Source `resume` injects only when + the marker is absent (context usually survives a resume). +- **UserPromptSubmit** (unchanged paths + one new): phrase trigger always injects; effort path + (payload `effort.level` → `CLAUDE_EFFORT` env) injects once per session — it is now documented and + working on ≥ 2.1.199; `FABLE_MODE=1` also injects once per session as a belt-and-suspenders for + sessions whose settings predate the SessionStart registration. + +Marker files stay in `tempfile.gettempdir()`, keyed by session_id, shared by both events. + +### 3. New `fable-code.md` — the Claude Code-native behavior layer + +An **original distillation** (not a verbatim prompt dump) of how Fable 5 operates inside Claude Code, +which the consumer prompt cannot express. Sections: + +- **Final-message contract:** everything the user needs lives in the last message of the turn; lead + with the outcome ("what happened / what was found" in sentence one); supporting detail after. +- **Readable over concise:** shorten by *selecting* what matters, not by compressing prose into + fragments, arrow chains (`A → B → fails`), or invented shorthand; complete sentences; no codenames + the reader must reverse-engineer; explain in place rather than referencing earlier labels. +- **Working rhythm:** one line on intent before the first tool call; surface load-bearing findings and + direction changes when they happen; otherwise no step-narration between tool calls. +- **Tool discipline:** batch independent calls in one block; structured tools (Grep/Glob/Read) over + shell pipelines; `file:line` references; read the exact region before editing it *this session*; + your own edit invalidates your last read; absolute paths. +- **Code & comments:** match surrounding idiom; a comment states only a constraint the code can't + show — never provenance or PR-narration. +- **Autonomy & honesty:** proceed on reversible in-scope actions; confirm before irreversible or + outward-facing ones; inspect before overwrite/delete; report failures with output, state skips, + say "done" only after verification. When the user is *describing* a problem, deliver the assessment + and stop — don't auto-fix. +- **End-of-turn rule:** if the closing paragraph is a plan, promise, or question answerable by a tool, + keep working instead of ending the turn. + +Provenance labelled honestly in the file header (distilled from observed first-party Fable 5 Claude +Code behavior, 2026-07). Target ≤ 250 lines. + +### 4. `skills/fable/SKILL.md` — a real skill + +`/fable` (user-invocable, model-invocable on "fable mode" phrasing). Body: read +`~/.claude/FABLE_PLAYBOOK.md` and `~/.claude/fable-code.md`, adopt as standing discipline for the +session, confirm activation in one line. Installer copies to `~/.claude/skills/fable/`; uninstaller +removes it. + +### 5. Playbook voice-layer amendment + +`FABLE_PLAYBOOK.md` gains a short "Claude Code layer (first-party)" subsection under the voice layer: +final-message completeness, readable-over-concise (anti-fragment, anti-arrow-chain), assessment mode. +Corrects the current layer's over-weighting of silence. Existing measured sections untouched. + +### 6. Install / uninstall / tests / docs + +- `install.py`: copy `fable-code.md`, `skills/fable/`; merge SessionStart hook entry. +- `uninstall.py`: remove both; strip the SessionStart entry (leave foreign hooks intact, as today). +- `settings.fragment.json`: add the SessionStart registration. +- Tests (pytest, stdlib-only, cross-platform): fable-trigger SessionStart × {FABLE_MODE set/unset}, + UserPromptSubmit × {phrase, effort payload, CLAUDE_EFFORT env, FABLE_MODE, none}, marker dedupe + across events, uninstall round-trip including the new entries. +- README: quickstart unchanged; "what's in the bundle" and honest-ceiling sections updated; + CHANGELOG entry. + +## Alternatives considered + +- **Everything via `--append-system-prompt-file`** (launcher concatenates prompt+playbook): simplest, + but plain `claude` sessions get nothing and the hook path stays broken. Rejected as sole mechanism; + the launcher *does* carry `fable-code.md` this way. +- **Skill-only activation:** zero background cost but "always-on" discipline becomes opt-in per + session; the measured finding (verification dies on willpower) argues against. Kept as one of three + activation paths, not the only one. + +## Out of scope + +- Modifying `test-after-edit.py` (works; has its own WIP on this branch). +- Re-measuring the trace statistics in FABLE_PLAYBOOK.md. +- The repo's pending uncommitted WIP (CHANGELOG/SECURITY/test-after-edit changes) — left as-is. + +## Decisions taken on the user's behalf (flag on review) + +1. Hybrid activation chosen (recommended option; user was AFK at the architecture question). +2. Launcher default switches from consumer `fable-system.md` to the new `fable-code.md`; the consumer + prompt remains bundled. Veto restores the old flag value in one line. +3. Model pinned to `claude-opus-4-8` explicitly (product premise), not `opus` alias. From aa2ee7cf0cb6e755e8fb56af1775aeb12ad03617 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:49:23 +0300 Subject: [PATCH 05/13] Fix "Invalid JSON provided for --settings" on PowerShell 5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows PowerShell 5.1 strips embedded quotes when building the native command line, so the inline --settings JSON reached claude as {ultracode: true} and failed validation. Reproduced end to end by driving fable.ps1 through powershell.exe with an argv-capturing fake claude. Pass a settings file instead of inline JSON: ship shell/ultracode.settings.json, install it to ~/.claude, and point both launchers --ultra path at it — a file path survives quoting on every shell and PowerShell version. The doctor now checks the file, and tests/test_launchers.py guards the regression (static no-inline-JSON check plus a live PowerShell 5.1 argv test). Fixes #2 --- CHANGELOG.md | 5 ++++ hooks/fable-doctor.py | 1 + install.py | 1 + shell/fable.ps1 | 6 ++++- shell/fable.zsh | 5 +++- shell/ultracode.settings.json | 1 + tests/test_fable_doctor.py | 3 ++- tests/test_install.py | 1 + tests/test_launchers.py | 47 +++++++++++++++++++++++++++++++++++ tests/test_uninstall.py | 1 + uninstall.py | 1 + 11 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 shell/ultracode.settings.json create mode 100644 tests/test_launchers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db0d0a..444fdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- "Invalid JSON provided for --settings" on Windows PowerShell 5.1 (#2): PS 5.1 + strips embedded quotes when building the native command line, so the inline + `--settings '{"ultracode": true}'` reached claude as `{ultracode: true}`. The + launchers now pass a settings *file* (`~/.claude/ultracode.settings.json`, + shipped and installed), which survives quoting on every shell and PS version. - The playbook injector never actually fired: the effort path needs `effort` in the hook payload or `CLAUDE_EFFORT` in the hook environment, which Claude Code ≤ 2.1.198 did not provide, and the trigger phrases were undiscoverable. The diff --git a/hooks/fable-doctor.py b/hooks/fable-doctor.py index c18e22b..7c925f0 100644 --- a/hooks/fable-doctor.py +++ b/hooks/fable-doctor.py @@ -50,6 +50,7 @@ def check_python(): "FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md", + "ultracode.settings.json", os.path.join("hooks", "fable-trigger.py"), os.path.join("hooks", "test-after-edit.py"), os.path.join("skills", "fable", "SKILL.md"), diff --git a/install.py b/install.py index 33a5c6a..f580f2e 100755 --- a/install.py +++ b/install.py @@ -104,6 +104,7 @@ def main(): print("-> fable behavior layer (Claude Code)") copy_into("fable-code.md", CLAUDE) + copy_into("shell/ultracode.settings.json", CLAUDE) print("-> skills (all bundled) + agent") skills_dir = os.path.join(REPO, "skills") diff --git a/shell/fable.ps1 b/shell/fable.ps1 index b58aedd..3ab99db 100644 --- a/shell/fable.ps1 +++ b/shell/fable.ps1 @@ -18,7 +18,11 @@ function fable { $extra = @() if ($rest.Count -gt 0 -and ($rest[0] -eq '--ultra' -or $rest[0] -eq '-u')) { $rest = @($rest | Select-Object -Skip 1) - $extra = @('--settings', '{"ultracode": true}') + # A file path, not inline JSON: Windows PowerShell 5.1 strips the inner + # quotes when building the native command line, so {"ultracode": true} + # reaches claude as {ultracode: true} — "Invalid JSON provided for + # --settings" (issue #2). A path survives quoting on every PS version. + $extra = @('--settings', "$HOME\.claude\ultracode.settings.json") } $env:FABLE_MODE = "1" try { diff --git a/shell/fable.zsh b/shell/fable.zsh index 9cda84b..caa3bd0 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -17,7 +17,10 @@ fable() { local -a extra if [[ "$1" == "--ultra" || "$1" == "-u" ]]; then shift - extra=(--settings '{"ultracode": true}') + # A file path, not inline JSON: PowerShell 5.1 strips embedded quotes from + # native-command args, and inline JSON breaks the same way if copy-pasted + # across shells (issue #2). A path survives quoting everywhere. + extra=(--settings "$HOME/.claude/ultracode.settings.json") fi FABLE_MODE=1 claude --model claude-opus-4-8 \ --append-system-prompt-file "$HOME/.claude/fable-code.md" \ diff --git a/shell/ultracode.settings.json b/shell/ultracode.settings.json new file mode 100644 index 0000000..84abc43 --- /dev/null +++ b/shell/ultracode.settings.json @@ -0,0 +1 @@ +{"ultracode": true} diff --git a/tests/test_fable_doctor.py b/tests/test_fable_doctor.py index 105b970..dc66974 100644 --- a/tests/test_fable_doctor.py +++ b/tests/test_fable_doctor.py @@ -26,7 +26,8 @@ def fake_install(home): (claude / "hooks").mkdir(parents=True) (claude / "skills" / "fable").mkdir(parents=True) (claude / "agents").mkdir(parents=True) - for rel in ("FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md"): + for rel in ("FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md", + "ultracode.settings.json"): (claude / rel).write_text("PLAYBOOK", encoding="utf-8") shutil.copy(REPO / "hooks" / "fable-trigger.py", claude / "hooks" / "fable-trigger.py") diff --git a/tests/test_install.py b/tests/test_install.py index 3b0198d..797db17 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -37,6 +37,7 @@ def test_install_copies_everything(tmp_path): assert (claude / "FABLE_PLAYBOOK.md").is_file() assert (claude / "fable-system.md").is_file() assert (claude / "fable-code.md").is_file() + assert (claude / "ultracode.settings.json").is_file() assert (claude / "skills" / "fable" / "SKILL.md").is_file() assert (claude / "hooks" / "fable-trigger.py").is_file() assert (claude / "hooks" / "test-after-edit.py").is_file() diff --git a/tests/test_launchers.py b/tests/test_launchers.py new file mode 100644 index 0000000..c20cdf1 --- /dev/null +++ b/tests/test_launchers.py @@ -0,0 +1,47 @@ +"""Launcher regression tests for issue #2: inline JSON passed to --settings is +mangled by PowerShell 5.1's native-argument quoting ({"ultracode": true} arrives +as {ultracode: true}). The launchers must therefore never pass inline JSON — +only a settings *file path*, which survives quoting in every shell.""" +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + + +def test_ultracode_settings_file_is_valid_json(): + data = json.loads((REPO / "shell" / "ultracode.settings.json").read_text(encoding="utf-8")) + assert data == {"ultracode": True} + + +def test_launchers_pass_no_inline_json(): + for name in ("fable.zsh", "fable.ps1"): + text = (REPO / "shell" / name).read_text(encoding="utf-8") + assert "--settings '{" not in text and '--settings "{' not in text, ( + f"{name} passes inline JSON to --settings; PowerShell 5.1 strips the " + "inner quotes (issue #2) — pass the settings file path instead") + assert "ultracode.settings.json" in text, f"{name} should use the settings file" + + +@pytest.mark.skipif(not shutil.which("powershell"), + reason="Windows PowerShell 5.1 not available") +def test_ultra_flag_survives_powershell51(tmp_path): + """Drive the real fable.ps1 through powershell.exe with a fake claude.cmd and + assert the --settings argument arrives brace-free (i.e. a file path).""" + capture = tmp_path / "argv.txt" + fake = tmp_path / "claude.cmd" + fake.write_text("@echo off\r\necho %*> \"%CAPTURE%\"\r\n", encoding="ascii") + ps1 = REPO / "shell" / "fable.ps1" + cmd = (f"$env:PATH = '{tmp_path};' + $env:PATH; " + f"$env:CAPTURE = '{capture}'; " + f". '{ps1}'; fable --ultra") + p = subprocess.run(["powershell", "-NoProfile", "-Command", cmd], + capture_output=True, text=True, timeout=60) + argv = capture.read_text(encoding="utf-8", errors="ignore") + assert "--settings" in argv, (p.stdout, p.stderr) + assert "{" not in argv, f"inline JSON leaked through (issue #2): {argv!r}" + assert "ultracode.settings.json" in argv diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index d4277bd..466c3a4 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -38,6 +38,7 @@ def test_uninstall_reverses_install(tmp_path): assert not (claude / "FABLE_PLAYBOOK.md").exists() assert not (claude / "fable-system.md").exists() assert not (claude / "fable-code.md").exists() + assert not (claude / "ultracode.settings.json").exists() assert not (claude / "hooks" / "fable-trigger.py").exists() assert not (claude / "hooks" / "fable-doctor.py").exists() assert not (claude / "skills" / "fable").exists() diff --git a/uninstall.py b/uninstall.py index 0c78b90..ea4bb90 100755 --- a/uninstall.py +++ b/uninstall.py @@ -136,6 +136,7 @@ def main(): rm_file(os.path.join(CLAUDE, "FABLE_PLAYBOOK.md")) rm_file(os.path.join(CLAUDE, "fable-system.md")) rm_file(os.path.join(CLAUDE, "fable-code.md")) + rm_file(os.path.join(CLAUDE, "ultracode.settings.json")) rm_file(os.path.join(CLAUDE, "agents", "grounding-verifier.md")) for name in bundled_skill_names(): rm_tree(os.path.join(CLAUDE, "skills", name)) From d4e7d5bb4f6a57a457d9e5349c404e8b695063ec Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 09:53:42 +0300 Subject: [PATCH 06/13] CI: run the PowerShell 5.1 launcher test only on Windows GitHub's macOS runners ship a 'powershell' binary that is just pwsh, so the which() guard alone let the test run on macOS, where the cmd-based fake claude can't execute. Windows PowerShell 5.1 only exists on Windows; gate the test on os.name too. --- tests/test_launchers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_launchers.py b/tests/test_launchers.py index c20cdf1..7d247ec 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -27,8 +27,9 @@ def test_launchers_pass_no_inline_json(): assert "ultracode.settings.json" in text, f"{name} should use the settings file" -@pytest.mark.skipif(not shutil.which("powershell"), - reason="Windows PowerShell 5.1 not available") +@pytest.mark.skipif(os.name != "nt" or not shutil.which("powershell"), + reason="Windows PowerShell 5.1 only exists on Windows; on " + "macOS/Linux runners `powershell` is just pwsh") def test_ultra_flag_survives_powershell51(tmp_path): """Drive the real fable.ps1 through powershell.exe with a fake claude.cmd and assert the --settings argument arrives brace-free (i.e. a file path).""" From 23b3bcf66259a27e2fc238b2723340446342a374 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:51:51 +0300 Subject: [PATCH 07/13] Fix playbook loss after compaction and de-duplicate activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the launcher, SessionStart now re-injects the playbook on source=compact whenever the session marker proves the mode was active (the auto/effort paths previously lost it forever), and drops the marker on source=clear so the once-per-session guard re-arms. The heavy path scans the transcript for a prior "Fable mode active" line, so a session already activated via the /fable skill doesn't get a second copy. Also: more ru/en task verbs in the auto heuristic (update/deploy/обнови/ запусти etc.) and week-old marker GC for %TEMP%. --- hooks/fable-trigger.py | 68 ++++++++++++++++++++++++----- tests/test_fable_trigger.py | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/hooks/fable-trigger.py b/hooks/fable-trigger.py index ea158a3..7ba6c7a 100644 --- a/hooks/fable-trigger.py +++ b/hooks/fable-trigger.py @@ -6,20 +6,27 @@ context); resume injects only if this session has no marker yet. This path works on every Claude Code version — it does not depend on the harness exposing effort to hooks. + Without FABLE_MODE, the session marker still matters: on + compact it proves the mode was active (auto/effort path), so + the playbook is re-injected into the wiped context; on clear + the marker is dropped so the once-per-session paths re-arm. UserPromptSubmit A trigger phrase ("use fable" / "fable mode" / "load fable") always injects (explicit intent; re-say after a compaction). Heavy effort (payload effort.level, else CLAUDE_EFFORT env), FABLE_MODE, or a task-shaped prompt (looks_complex heuristic; disable with FABLE_AUTO=0) injects once per session (marker - file keyed by session_id). + file keyed by session_id) — and not at all if the transcript + shows the /fable skill already activated this session. No trigger -> prints nothing -> the playbook costs zero tokens by default. """ import sys +import glob import json import re import os import tempfile +import time PLAYBOOK = os.path.expanduser(os.path.join("~", ".claude", "FABLE_PLAYBOOK.md")) TRIGGER = re.compile(r"\b(use fable|fable mode|load fable)\b", re.I) @@ -29,9 +36,11 @@ # engineering task. >= 2 points = complex. Disable with FABLE_AUTO=0. TASK_VERBS = re.compile( r"\b(implement|refactor|migrat\w*|build|creat\w*|add|fix|debug|integrat\w*|" - r"optimi[sz]\w*|rewrit\w*|design|install|set up" + r"optimi[sz]\w*|rewrit\w*|design|install|set up|updat\w*|upgrad\w*|" + r"remov\w*|delet\w*|deploy\w*|renam\w*|writ\w*" r"|сдела\w*|добав\w*|почин\w*|исправ\w*|реализу\w*|перепиш\w*|настро\w*|" - r"созда\w*|собер\w*|интегрир\w*|оптимизир\w*|мигрир\w*|разработ\w*)", re.I) + r"созда\w*|собер\w*|интегрир\w*|оптимизир\w*|мигрир\w*|разработ\w*|" + r"удал\w*|обнов\w*|напиш\w*|запуст\w*|перенес\w*|убер\w*)", re.I) MULTISTEP = re.compile(r"\b(затем|потом|после этого|then|steps?)\b|^\s*\d+[.)]\s", re.I | re.M) PATHISH = re.compile( @@ -82,6 +91,32 @@ def write_marker(path): open(path, "w").close() except Exception: pass # marker is best-effort; worst case is one duplicate injection + prune_stale_markers() + + +def prune_stale_markers(): + """Best-effort GC: a week-old session marker belongs to a dead session. + Windows never clears %TEMP%, so without this they accumulate forever.""" + cutoff = time.time() - 7 * 86400 + for p in glob.glob(os.path.join(tempfile.gettempdir(), "fable-loaded-*")): + try: + if os.path.getmtime(p) < cutoff: + os.remove(p) + except OSError: + pass + + +def transcript_has_activation(data): + """True if this session's transcript already contains a Fable activation — + either a previous injection or the /fable skill's confirmation line.""" + path = data.get("transcript_path") or "" + if not path or not os.path.isfile(path): + return False + try: + with open(path, encoding="utf-8", errors="ignore") as f: + return any("Fable mode active" in line for line in f) + except OSError: + return False def inject(event, why): @@ -111,13 +146,22 @@ def main(): marker = marker_path(data) if event == "SessionStart": - if not fable_mode(): - return source = str(data.get("source") or "startup").lower() - if source == "resume" and os.path.exists(marker): - return # context usually survives a resume; don't double-inject - write_marker(marker) - inject("SessionStart", "launcher") + if fable_mode(): + if source == "resume" and os.path.exists(marker): + return # context usually survives a resume; don't double-inject + write_marker(marker) + inject("SessionStart", "launcher") + return + # No launcher declaration. The marker is the proof that this session + # activated via the auto/effort/FABLE_MODE path earlier: + if source == "compact" and os.path.exists(marker): + inject("SessionStart", "compact") # compaction wiped it; restore + elif source == "clear": + try: + os.remove(marker) # fresh context: re-arm once-per-session paths + except OSError: + pass return prompt = data.get("prompt", "") or "" @@ -129,11 +173,15 @@ def main(): if not (phrase or heavy): return - # Heavy-only trigger: inject just once per session. + # Heavy-only trigger: inject just once per session. A "Fable mode active" + # line already in the transcript means the /fable skill (or an earlier + # injection) activated this session — don't add a duplicate playbook. if heavy and not phrase: if os.path.exists(marker): return write_marker(marker) + if transcript_has_activation(data): + return if phrase: why = "phrase" diff --git a/tests/test_fable_trigger.py b/tests/test_fable_trigger.py index e3a44ab..7873abe 100644 --- a/tests/test_fable_trigger.py +++ b/tests/test_fable_trigger.py @@ -3,6 +3,8 @@ import os import subprocess import sys +import tempfile +import time import uuid from pathlib import Path @@ -156,3 +158,88 @@ def test_simple_greeting_stays_silent(tmp_path): "session_id": str(uuid.uuid4())}, home) == "" assert run({"prompt": "what time is it", "session_id": str(uuid.uuid4())}, home) == "" + + +def test_compact_reinjects_without_fable_mode(tmp_path): + """Auto/effort-activated sessions lose the playbook on compaction; the + session marker is the proof the mode was active, so compact must re-inject + even when the launcher (FABLE_MODE) is not involved.""" + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + first = run({"prompt": "hi", "effort": "xhigh", "session_id": sid}, home) + assert first, "heavy effort should inject" + compact = run({"hook_event_name": "SessionStart", "source": "compact", + "session_id": sid}, home) + assert compact, "compaction wiped an activated session, must re-inject" + payload = json.loads(compact)["hookSpecificOutput"] + assert payload["hookEventName"] == "SessionStart" + assert "PLAYBOOK_MARKER_42" in payload["additionalContext"] + + +def test_compact_without_prior_activation_is_silent(tmp_path): + home = make_home(tmp_path) + out = run({"hook_event_name": "SessionStart", "source": "compact", + "session_id": str(uuid.uuid4())}, home) + assert out == "", "no marker, no FABLE_MODE - nothing to restore" + + +def test_clear_rearms_the_once_per_session_paths(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + first = run({"prompt": "hi", "effort": "xhigh", "session_id": sid}, home) + cleared = run({"hook_event_name": "SessionStart", "source": "clear", + "session_id": sid}, home) + again = run({"prompt": "hi", "effort": "xhigh", "session_id": sid}, home) + assert first, "heavy effort should inject" + assert cleared == "", "clear itself injects nothing without the launcher" + assert again, "context was wiped by /clear - the once-per-session guard must re-arm" + + +def test_transcript_activation_suppresses_reinjection(tmp_path): + """If the /fable skill already activated this session (its confirmation + line is in the transcript), the heavy path must not inject a duplicate.""" + home = make_home(tmp_path) + transcript = tmp_path / "transcript.jsonl" + transcript.write_text( + '{"text": "Fable mode active — playbook and behavior layer loaded."}\n', + encoding="utf-8") + out = run({"prompt": "hi", "effort": "xhigh", + "session_id": str(uuid.uuid4()), + "transcript_path": str(transcript)}, home) + assert out == "", "session already activated via /fable - no duplicate playbook" + + +def test_phrase_overrides_transcript_suppression(tmp_path): + """An explicit trigger phrase is intent (e.g. re-saying it after a + compaction) and must always inject, transcript or not.""" + home = make_home(tmp_path) + transcript = tmp_path / "transcript.jsonl" + transcript.write_text('{"text": "Fable mode active"}\n', encoding="utf-8") + out = run({"prompt": "use fable", "session_id": str(uuid.uuid4()), + "transcript_path": str(transcript)}, home) + assert out, "explicit phrase must always inject" + + +def test_en_update_deploy_prompt_auto_injects(tmp_path): + home = make_home(tmp_path) + out = run({"prompt": "Update the dependency pins and deploy the staging service", + "session_id": str(uuid.uuid4())}, home) + assert out, "update/deploy are task verbs and should trip the heuristic" + + +def test_ru_update_prompt_auto_injects(tmp_path): + home = make_home(tmp_path) + out = run({"prompt": "Обнови зависимости и запусти тесты, когда закончишь", + "session_id": str(uuid.uuid4())}, home) + assert out, "обнови/запусти are task verbs and should trip the heuristic" + + +def test_stale_loaded_markers_are_pruned(tmp_path): + home = make_home(tmp_path) + stale = Path(tempfile.gettempdir()) / ("fable-loaded-stale-" + uuid.uuid4().hex) + stale.touch() + old = time.time() - 8 * 86400 + os.utime(stale, (old, old)) + assert run({"prompt": "hi", "effort": "xhigh", + "session_id": str(uuid.uuid4())}, home) + assert not stale.exists(), "markers older than a week should be pruned" From bd250fbc9dff448c00d8b371263d853e62ceca49 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:52:17 +0300 Subject: [PATCH 08/13] Skip the test hook after failed edits and GC its debounce markers A failed Edit changes nothing on disk, so running the suite would only report a stale result - bail out when tool_response.success is false. Debounce stamps older than a week are pruned; Windows never clears %TEMP%, so they used to accumulate forever. --- hooks/test-after-edit.py | 18 ++++++++++++++++++ tests/test_test_after_edit.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/hooks/test-after-edit.py b/hooks/test-after-edit.py index d18d669..b2f4f70 100644 --- a/hooks/test-after-edit.py +++ b/hooks/test-after-edit.py @@ -25,6 +25,7 @@ the hook doesn't run the whole suite on every edit. """ import sys +import glob import os import json import time @@ -152,8 +153,21 @@ def find_root_and_cmd(start_dir): d = parent +def prune_stale_markers(): + """Best-effort GC: week-old debounce stamps belong to dead sessions. + Windows never clears %TEMP%, so without this they accumulate forever.""" + cutoff = time.time() - 7 * 86400 + for p in glob.glob(os.path.join(tempfile.gettempdir(), "fable-testhook-*")): + try: + if os.path.getmtime(p) < cutoff: + os.remove(p) + except OSError: + pass + + def debounced(root): """True if we ran for this root within DEBOUNCE seconds; else stamp and return False.""" + prune_stale_markers() h = hashlib.sha1(root.encode()).hexdigest()[:16] marker = os.path.join(tempfile.gettempdir(), f"fable-testhook-{h}") now = time.time() @@ -177,6 +191,10 @@ def main(): if data.get("tool_name") not in ("Edit", "Write", "MultiEdit"): return + resp = data.get("tool_response") + if isinstance(resp, dict) and resp.get("success") is False: + return # the edit itself failed; nothing new on disk to test + ti = data.get("tool_input") or {} fpath = ti.get("file_path") or ti.get("path") or "" if not fpath: diff --git a/tests/test_test_after_edit.py b/tests/test_test_after_edit.py index 5f35cf0..afc2e16 100644 --- a/tests/test_test_after_edit.py +++ b/tests/test_test_after_edit.py @@ -13,8 +13,10 @@ HOOK = REPO / "hooks" / "test-after-edit.py" -def run(tool_input, tool_name="Edit", env_extra=None): +def run(tool_input, tool_name="Edit", env_extra=None, tool_response=None): payload = {"tool_name": tool_name, "tool_input": tool_input} + if tool_response is not None: + payload["tool_response"] = tool_response env = dict(os.environ) env["FABLE_TEST_HOOK_DEBOUNCE"] = "0" # don't let debounce swallow back-to-back tests if env_extra: @@ -113,3 +115,29 @@ def test_allowlist_blocks_root_outside_it(tmp_path): elsewhere = str(tmp_path.parent / "some-other-trusted-tree") assert run({"file_path": str(src)}, env_extra={"FABLE_TEST_HOOK_ALLOW": elsewhere}) == "" + + +def test_failed_edit_skips_test_run(tmp_path): + """When the Edit tool itself failed, nothing changed on disk - running the + suite would only report a stale result.""" + src = make_py_project(tmp_path, passing=True) + assert run({"file_path": str(src)}, tool_response={"success": False}) == "" + + +def test_successful_edit_response_still_runs(tmp_path): + src = make_py_project(tmp_path, passing=True) + out = run({"file_path": str(src)}, tool_response={"success": True}) + assert "passed" in out, out + + +def test_stale_testhook_markers_are_pruned(tmp_path): + import tempfile + import time + import uuid + stale = Path(tempfile.gettempdir()) / ("fable-testhook-stale-" + uuid.uuid4().hex) + stale.touch() + old = time.time() - 8 * 86400 + os.utime(stale, (old, old)) + src = make_py_project(tmp_path, passing=True) + assert "passed" in run({"file_path": str(src)}) + assert not stale.exists(), "markers older than a week should be pruned" From b82e0c2571fe8753cc89f60f1970520998366e48 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:52:44 +0300 Subject: [PATCH 09/13] Install launchers into ~/.claude and preserve user skills on collision The shell profile used to source the launcher straight from the clone: deleting or moving the checkout broke every new shell, and a re-install from a new path silently kept the stale line. install.py now copies fable.ps1/fable.zsh into ~/.claude/shell, sources the stable copy from the profile (both PowerShell 7 and 5.1 when present), and replaces stale launcher lines instead of keeping them. Skills are installed with a .fable-mode-bundled marker; a same-named directory without the marker is the user's own work and is preserved under ~/.claude/backups/skills instead of being rmtree'd. uninstall.py removes only marked directories, strips the launcher from every profile, and deletes the ~/.claude/shell copies. fable.ps1 doctor now falls back across python/py/python3 instead of hardcoding python. --- install.py | 86 ++++++++++++++++++++++++++++++----------- shell/fable.ps1 | 19 ++++++--- shell/fable.zsh | 6 +-- tests/test_install.py | 53 ++++++++++++++++++++++++- tests/test_launchers.py | 8 ++++ tests/test_uninstall.py | 24 +++++++++++- uninstall.py | 36 ++++++++++++----- 7 files changed, 189 insertions(+), 43 deletions(-) diff --git a/install.py b/install.py index f580f2e..f2af34e 100755 --- a/install.py +++ b/install.py @@ -25,63 +25,103 @@ from merge_settings import merge # noqa: E402 +BUNDLED_MARKER = ".fable-mode-bundled" + + def copy_into(rel_file, dst_dir): shutil.copy(os.path.join(REPO, rel_file), os.path.join(dst_dir, os.path.basename(rel_file))) -def copytree_idempotent(src, dst): - if os.path.exists(dst): +def install_skill(src, dst): + """Copy one bundled skill into ~/.claude/skills. A pre-existing directory + without our marker file was NOT installed by fable-mode — it's the user's + own work, so move it to ~/.claude/backups/skills/ instead of + deleting it. Re-runs (marker present) just refresh our copy.""" + marker = os.path.join(dst, BUNDLED_MARKER) + if os.path.isdir(dst) and not os.path.isfile(marker): + bak = os.path.join(CLAUDE, "backups", "skills", os.path.basename(dst)) + if os.path.exists(bak): + shutil.rmtree(dst) # the user's copy is already preserved there + else: + os.makedirs(os.path.dirname(bak), exist_ok=True) + shutil.move(dst, bak) + print(" existing skill not installed by fable-mode - saved to {}".format(bak)) + elif os.path.exists(dst): shutil.rmtree(dst) shutil.copytree(src, dst) + open(marker, "w").close() -def append_once(path, marker, block): - """Append `block` to `path` unless `marker` already appears in it.""" +def ensure_launcher(path, marker, block): + """Make `block` the launcher entry in `path`. A line referencing `marker` + that points elsewhere (an old clone path) is replaced — append-once + semantics would silently keep the stale, broken line.""" existing = "" if os.path.exists(path): with open(path, encoding="utf-8") as f: existing = f.read() - if marker in existing: + if block.strip("\n") in existing: print(" launcher already present in {} - skipped".format(path)) return + out, replaced = [], False + for line in existing.splitlines(keepends=True): + if marker in line: + replaced = True + while out and (out[-1].lstrip().startswith("# Fable mode") + or out[-1].strip() == ""): + out.pop() + continue + out.append(line) + existing = "".join(out) parent = os.path.dirname(path) if parent: os.makedirs(parent, exist_ok=True) - with open(path, "a", encoding="utf-8") as f: + with open(path, "w", encoding="utf-8") as f: if existing and not existing.endswith("\n"): - f.write("\n") - f.write(block) - print(" added launcher to {}".format(path)) + existing += "\n" + f.write(existing + block) + print(" {} launcher in {}".format("updated stale" if replaced else "added", path)) -def powershell_profile_path(): - """Ask the available PowerShell for its $PROFILE; fall back to the PS7 default.""" +def powershell_profile_paths(): + """$PROFILE of every PowerShell present (pwsh 7 and Windows PowerShell 5.1). + A user may open either shell, so the launcher goes into each.""" + paths = [] for exe in ("pwsh", "powershell"): if shutil.which(exe): try: out = subprocess.run([exe, "-NoProfile", "-Command", "$PROFILE"], capture_output=True, text=True, timeout=20) p = out.stdout.strip() - if p: - return p + if p and p not in paths: + paths.append(p) except Exception: pass - return os.path.join(HOME, "Documents", "PowerShell", - "Microsoft.PowerShell_profile.ps1") + if not paths: + paths.append(os.path.join(HOME, "Documents", "PowerShell", + "Microsoft.PowerShell_profile.ps1")) + return paths def install_launcher(): + """Copy the launchers into ~/.claude/shell and source them from there. + Sourcing the stable copy (not the clone) keeps every profile working if + the checkout is later moved or deleted.""" + shell_dir = os.path.join(CLAUDE, "shell") + os.makedirs(shell_dir, exist_ok=True) + copy_into("shell/fable.ps1", shell_dir) + copy_into("shell/fable.zsh", shell_dir) if IS_WINDOWS: - profile = powershell_profile_path() - src = os.path.join(REPO, "shell", "fable.ps1") - block = ('\n# Fable mode (added by fable-mode/install.py)\n. "{}"\n'.format(src)) - append_once(profile, "fable.ps1", block) + block = ('\n# Fable mode (added by fable-mode/install.py)\n. "{}"\n' + .format(os.path.join(shell_dir, "fable.ps1"))) + for profile in powershell_profile_paths(): + ensure_launcher(profile, "fable.ps1", block) else: shell = os.environ.get("SHELL", "") rc = os.path.join(HOME, ".bashrc" if "bash" in shell else ".zshrc") - src = os.path.join(REPO, "shell", "fable.zsh") - block = ('\n# Fable mode (added by fable-mode/install.py)\nsource "{}"\n'.format(src)) - append_once(rc, "fable.zsh", block) + block = ('\n# Fable mode (added by fable-mode/install.py)\nsource "{}"\n' + .format(os.path.join(shell_dir, "fable.zsh"))) + ensure_launcher(rc, "fable.zsh", block) def main(): @@ -111,7 +151,7 @@ def main(): for name in sorted(os.listdir(skills_dir)): src = os.path.join(skills_dir, name) if os.path.isdir(src): - copytree_idempotent(src, os.path.join(CLAUDE, "skills", name)) + install_skill(src, os.path.join(CLAUDE, "skills", name)) copy_into("agents/grounding-verifier.md", os.path.join(CLAUDE, "agents")) print("-> launcher") diff --git a/shell/fable.ps1 b/shell/fable.ps1 index 3ab99db..11d4968 100644 --- a/shell/fable.ps1 +++ b/shell/fable.ps1 @@ -1,5 +1,7 @@ -# Fable mode launcher (PowerShell). Dot-source from your profile, or: -# . C:\path\to\fable-mode\shell\fable.ps1 +# Fable mode launcher (PowerShell). install.py copies this file to +# ~\.claude\shell\fable.ps1 and dot-sources it from your $PROFILE, so the +# cloned repo can be moved or deleted after install. Manual use: +# . $HOME\.claude\shell\fable.ps1 # # `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior # layer appended, xhigh effort, FABLE_MODE=1 declared so @@ -7,12 +9,19 @@ # `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent # workflows for substantive tasks (heavy on tokens). # `fable doctor` Verify the whole install/activation chain mechanically. -# -# install.ps1 copies fable-code.md and fable-doctor.py into ~\.claude for you. function fable { $rest = @($args) if ($rest.Count -gt 0 -and $rest[0] -eq 'doctor') { - & python "$HOME\.claude\hooks\fable-doctor.py" @($rest | Select-Object -Skip 1) + # Not every Windows box has `python` on PATH; try the common launchers. + $py = $null + foreach ($cand in 'python', 'py', 'python3') { + if (Get-Command $cand -ErrorAction SilentlyContinue) { $py = $cand; break } + } + if (-not $py) { + Write-Error 'Python not found on PATH - cannot run fable doctor.' + return + } + & $py "$HOME\.claude\hooks\fable-doctor.py" @($rest | Select-Object -Skip 1) return } $extra = @() diff --git a/shell/fable.zsh b/shell/fable.zsh index caa3bd0..a3c0903 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -1,4 +1,6 @@ -# Fable mode launcher. Add to ~/.zshrc, or: `source ~/path/to/fable-mode/shell/fable.zsh` +# Fable mode launcher. install.py copies this file to ~/.claude/shell/fable.zsh +# and sources it from your shell rc, so the cloned repo can be moved or deleted +# after install. Manual use: `source ~/.claude/shell/fable.zsh` # # `fable` Claude Code pinned to Opus 4.8, Fable Claude-Code behavior # layer appended, xhigh effort, FABLE_MODE=1 declared so @@ -6,8 +8,6 @@ # `fable --ultra` Same, plus ultracode: the harness auto-runs multi-agent # workflows for substantive tasks (heavy on tokens). # `fable doctor` Verify the whole install/activation chain mechanically. -# -# install.py copies fable-code.md and fable-doctor.py into ~/.claude for you. fable() { if [[ "$1" == "doctor" ]]; then shift diff --git a/tests/test_install.py b/tests/test_install.py index 797db17..ba86a1f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -25,7 +25,7 @@ def sandbox(tmp_path, windows): inst.CLAUDE = str(tmp_path / ".claude") inst.IS_WINDOWS = windows inst._profile = tmp_path / "profile.ps1" - inst.powershell_profile_path = lambda: str(inst._profile) + inst.powershell_profile_paths = lambda: [str(inst._profile)] return inst @@ -77,4 +77,53 @@ def test_install_unix_launcher(tmp_path, monkeypatch): inst.main() rc = tmp_path / ".zshrc" assert rc.is_file() - assert "fable.zsh" in rc.read_text(encoding="utf-8") + text = rc.read_text(encoding="utf-8") + # sourced from the stable ~/.claude copy, not from the (deletable) clone + assert str(tmp_path / ".claude" / "shell" / "fable.zsh") in text + assert str(REPO) not in text + + +def test_launcher_sourced_from_claude_not_repo(tmp_path): + """The clone is deletable after install: the profile must reference the + copy under ~/.claude, never the checkout path.""" + inst = sandbox(tmp_path, windows=True) + inst.main() + claude = tmp_path / ".claude" + assert (claude / "shell" / "fable.ps1").is_file() + assert (claude / "shell" / "fable.zsh").is_file() + profile = (tmp_path / "profile.ps1").read_text(encoding="utf-8") + assert str(claude / "shell" / "fable.ps1") in profile + assert str(REPO) not in profile + + +def test_stale_launcher_line_is_replaced(tmp_path): + """Re-installing from a new clone path must fix a profile line that points + at the old location, not silently keep it.""" + inst = sandbox(tmp_path, windows=True) + profile = tmp_path / "profile.ps1" + profile.write_text( + '\n# Fable mode (added by fable-mode/install.py)\n' + '. "C:\\old-clone\\shell\\fable.ps1"\n', encoding="utf-8") + inst.main() + text = profile.read_text(encoding="utf-8") + assert "old-clone" not in text + assert text.count("fable.ps1") == 1 + assert str(tmp_path / ".claude" / "shell" / "fable.ps1") in text + + +def test_preexisting_user_skill_is_backed_up(tmp_path): + """A skills/ dir that fable-mode did not install is the user's own + work - it must be preserved, not rmtree'd.""" + inst = sandbox(tmp_path, windows=True) + mine = tmp_path / ".claude" / "skills" / "webapp-testing" + mine.mkdir(parents=True) + (mine / "SKILL.md").write_text("MY CUSTOM VERSION", encoding="utf-8") + inst.main() + bak = tmp_path / ".claude" / "backups" / "skills" / "webapp-testing" + assert (bak / "SKILL.md").read_text(encoding="utf-8") == "MY CUSTOM VERSION" + installed = tmp_path / ".claude" / "skills" / "webapp-testing" + assert (installed / ".fable-mode-bundled").is_file() + assert "MY CUSTOM" not in (installed / "SKILL.md").read_text(encoding="utf-8") + # a re-run must not clobber the preserved backup with our own copy + inst.main() + assert (bak / "SKILL.md").read_text(encoding="utf-8") == "MY CUSTOM VERSION" diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 7d247ec..078ad1d 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -27,6 +27,14 @@ def test_launchers_pass_no_inline_json(): assert "ultracode.settings.json" in text, f"{name} should use the settings file" +def test_ps1_doctor_resolves_python_fallback(): + """`python` isn't guaranteed on Windows PATH — the doctor subcommand must + fall back across the common launcher names instead of failing outright.""" + text = (REPO / "shell" / "fable.ps1").read_text(encoding="utf-8") + for cand in ("'python'", "'py'", "'python3'"): + assert cand in text, f"fable doctor should try {cand} on PATH" + + @pytest.mark.skipif(os.name != "nt" or not shutil.which("powershell"), reason="Windows PowerShell 5.1 only exists on Windows; on " "macOS/Linux runners `powershell` is just pwsh") diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 466c3a4..27a3181 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -17,7 +17,7 @@ def sandbox(mod, tmp_path, windows=True): mod.HOME = str(tmp_path) mod.CLAUDE = str(tmp_path / ".claude") mod.IS_WINDOWS = windows - mod.powershell_profile_path = lambda: str(tmp_path / "profile.ps1") + mod.powershell_profile_paths = lambda: [str(tmp_path / "profile.ps1")] return mod @@ -43,6 +43,8 @@ def test_uninstall_reverses_install(tmp_path): assert not (claude / "hooks" / "fable-doctor.py").exists() assert not (claude / "skills" / "fable").exists() assert not (claude / "skills" / "ground").exists() + assert not (claude / "shell" / "fable.ps1").exists() + assert not (claude / "shell" / "fable.zsh").exists() assert "fable.ps1" not in (tmp_path / "profile.ps1").read_text(encoding="utf-8") s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) @@ -51,3 +53,23 @@ def test_uninstall_reverses_install(tmp_path): assert "UserPromptSubmit" not in s["hooks"] assert "PostToolUse" not in s["hooks"] assert s["alwaysThinkingEnabled"] is True + + +def test_uninstall_leaves_skills_it_did_not_install(tmp_path): + """Skill removal is gated on the .fable-mode-bundled marker: a same-named + directory the user owns (no marker) must survive the uninstall.""" + inst = sandbox(load("fable_install", "install.py"), tmp_path) + uninst = sandbox(load("fable_uninstall", "uninstall.py"), tmp_path) + inst.main() + + claude = tmp_path / ".claude" + (claude / "skills" / "ground" / ".fable-mode-bundled").unlink() # now "user-owned" + foreign = claude / "skills" / "my-own-skill" + foreign.mkdir(parents=True) + (foreign / "SKILL.md").write_text("mine", encoding="utf-8") + + uninst.main() + + assert (claude / "skills" / "ground" / "SKILL.md").is_file() + assert (foreign / "SKILL.md").is_file() + assert not (claude / "skills" / "fable").exists() diff --git a/uninstall.py b/uninstall.py index ea4bb90..22db8ca 100755 --- a/uninstall.py +++ b/uninstall.py @@ -22,6 +22,7 @@ HOME = os.path.expanduser("~") CLAUDE = os.path.join(HOME, ".claude") IS_WINDOWS = os.name == "nt" +BUNDLED_MARKER = ".fable-mode-bundled" def rm_file(path): @@ -30,10 +31,16 @@ def rm_file(path): print(" removed " + path) -def rm_tree(path): - if os.path.isdir(path): +def rm_skill(path): + """Remove a skill directory only if install.py marked it as ours. A + same-named directory without the marker is the user's own work.""" + if not os.path.isdir(path): + return + if os.path.isfile(os.path.join(path, BUNDLED_MARKER)): shutil.rmtree(path) print(" removed " + path) + else: + print(" left {} in place (not installed by fable-mode)".format(path)) def bundled_skill_names(): @@ -66,25 +73,30 @@ def strip_launcher(path, marker): print(" removed launcher from " + path) -def powershell_profile_path(): +def powershell_profile_paths(): + """$PROFILE of every PowerShell present — install.py writes to each.""" import subprocess + paths = [] for exe in ("pwsh", "powershell"): if shutil.which(exe): try: out = subprocess.run([exe, "-NoProfile", "-Command", "$PROFILE"], capture_output=True, text=True, timeout=20) p = out.stdout.strip() - if p: - return p + if p and p not in paths: + paths.append(p) except Exception: pass - return os.path.join(HOME, "Documents", "PowerShell", - "Microsoft.PowerShell_profile.ps1") + if not paths: + paths.append(os.path.join(HOME, "Documents", "PowerShell", + "Microsoft.PowerShell_profile.ps1")) + return paths def remove_launcher(): if IS_WINDOWS: - strip_launcher(powershell_profile_path(), "fable.ps1") + for profile in powershell_profile_paths(): + strip_launcher(profile, "fable.ps1") else: for rc in (".zshrc", ".bashrc"): strip_launcher(os.path.join(HOME, rc), "fable.zsh") @@ -138,8 +150,14 @@ def main(): rm_file(os.path.join(CLAUDE, "fable-code.md")) rm_file(os.path.join(CLAUDE, "ultracode.settings.json")) rm_file(os.path.join(CLAUDE, "agents", "grounding-verifier.md")) + rm_file(os.path.join(CLAUDE, "shell", "fable.ps1")) + rm_file(os.path.join(CLAUDE, "shell", "fable.zsh")) + try: + os.rmdir(os.path.join(CLAUDE, "shell")) + except OSError: + pass # not empty or absent — leave it for name in bundled_skill_names(): - rm_tree(os.path.join(CLAUDE, "skills", name)) + rm_skill(os.path.join(CLAUDE, "skills", name)) print("-> launcher") remove_launcher() From aa92b35c8ce16527bc400984c7c7b2f3dc3149d0 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:53:02 +0300 Subject: [PATCH 10/13] fable doctor: check launcher lines, settings.local.json, and CLI flags The most fragile link was unchecked: a profile line sourcing a launcher file that no longer exists breaks every new shell - that is now a hard failure, while no launcher line at all is only a warning. Hooks registered in settings.local.json count as registered. The CLI probe also greps claude --help for --effort / --append-system-prompt-file (understanding the --append-system-prompt[-file] bracket shorthand) and the installed ~/.claude/shell launcher copies joined CORE_FILES. --- hooks/fable-doctor.py | 92 +++++++++++++++++++++++++++++++++++--- tests/test_fable_doctor.py | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/hooks/fable-doctor.py b/hooks/fable-doctor.py index 7c925f0..a8c695e 100644 --- a/hooks/fable-doctor.py +++ b/hooks/fable-doctor.py @@ -53,6 +53,8 @@ def check_python(): "ultracode.settings.json", os.path.join("hooks", "fable-trigger.py"), os.path.join("hooks", "test-after-edit.py"), + os.path.join("shell", "fable.ps1"), + os.path.join("shell", "fable.zsh"), os.path.join("skills", "fable", "SKILL.md"), os.path.join("agents", "grounding-verifier.md"), ] @@ -68,22 +70,31 @@ def check_files(): def check_settings(): + """Hooks may live in settings.json (where install.py puts them) or in + settings.local.json (a legitimate hand-managed location) — accept both.""" path = os.path.join(CLAUDE, "settings.json") if not os.path.isfile(path): fail("settings.json missing (re-run install.py)") return try: with open(path, encoding="utf-8") as f: - d = json.load(f) + docs = [json.load(f)] except Exception as e: fail("settings.json unreadable: {}".format(e)) return - hooks = d.get("hooks", {}) + local = os.path.join(CLAUDE, "settings.local.json") + if os.path.isfile(local): + try: + with open(local, encoding="utf-8") as f: + docs.append(json.load(f)) + except Exception as e: + warn("settings.local.json unreadable: {}".format(e)) for event, needle in (("SessionStart", "fable-trigger.py"), ("UserPromptSubmit", "fable-trigger.py"), ("PostToolUse", "test-after-edit.py")): - cmds = [h.get("command", "") for e in hooks.get(event, []) - for h in e.get("hooks", [])] + cmds = [h.get("command", "") for d in docs + for ent in d.get("hooks", {}).get(event, []) + for h in ent.get("hooks", [])] hit = next((c for c in cmds if needle in c), None) if not hit: fail("{} not registered for {} (re-run install.py)".format(needle, event)) @@ -93,10 +104,66 @@ def check_settings(): interp = m.group(1) if m else hit.split()[0] if not (os.path.isfile(interp) or shutil.which(interp)): fail("hook interpreter not found: " + interp) - if d.get("alwaysThinkingEnabled") is not True: + if not any(d.get("alwaysThinkingEnabled") is True for d in docs): warn("alwaysThinkingEnabled is off - reasoning-density lever missing") +def launcher_profile_candidates(): + """Shell startup files that may carry the fable launcher line. Static + defaults always; live $PROFILE queries only when CLI probes are allowed + (they spawn a shell, and profiles can live in redirected Documents).""" + docs = os.path.join(HOME, "Documents") + cands = [ + os.path.join(docs, "PowerShell", "Microsoft.PowerShell_profile.ps1"), + os.path.join(docs, "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"), + os.path.join(HOME, ".zshrc"), + os.path.join(HOME, ".bashrc"), + ] + if os.environ.get("FABLE_DOCTOR_SKIP_CLI") != "1": + for exe in ("pwsh", "powershell"): + if shutil.which(exe): + try: + out = subprocess.run([exe, "-NoProfile", "-Command", "$PROFILE"], + capture_output=True, text=True, timeout=20) + p = out.stdout.strip() + if p and p not in cands: + cands.append(p) + except Exception: + pass + return cands + + +def check_launcher(): + """The most fragile link: a profile line sourcing a launcher file that no + longer exists breaks every new shell — that is a hard failure. No line at + all is only a warning (/fable and auto-activation work without it).""" + found = False + for path in launcher_profile_candidates(): + if not os.path.isfile(path): + continue + try: + with open(path, encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except OSError: + continue + for line in lines: + if ("fable.ps1" not in line and "fable.zsh" not in line) \ + or line.lstrip().startswith("#"): + continue + found = True + m = re.search(r'["\']([^"\']*fable\.(?:ps1|zsh))["\']', line) + target = os.path.expanduser(m.group(1)) if m else None + if target and os.path.isfile(target): + ok("launcher registered in {}".format(path)) + else: + fail("launcher line in {} points to a missing file " + "(re-run install.py): {}".format(path, line.strip())) + if not found: + warn("no `fable` launcher line found in a shell profile - the `fable` " + "command won't exist; /fable and auto-activation still work " + "(re-run install.py to add it)") + + def check_claude_cli(): if os.environ.get("FABLE_DOCTOR_SKIP_CLI") == "1": warn("claude CLI probe skipped (FABLE_DOCTOR_SKIP_CLI=1)") @@ -121,6 +188,20 @@ def check_claude_cli(): else: warn("claude {} < 2.1.199: effort not exposed to hooks; " "phrase/FABLE_MODE/SessionStart paths unaffected".format(label)) + try: + help_out = subprocess.run(["claude", "--help"], capture_output=True, + text=True, timeout=30).stdout + except Exception: + help_out = "" + for flag in ("--effort", "--append-system-prompt-file"): + variants = [flag] + if flag.endswith("-file"): + # the CLI lists file variants as a shorthand family, e.g. + # "--append-system-prompt[-file]" + variants.append(flag[:-len("-file")] + "[-file]") + if not any(v in help_out for v in variants): + warn("claude --help does not list {} - the fable launcher may fail " + "on this CLI version".format(flag)) def _fire(payload, env_extra): @@ -185,6 +266,7 @@ def main(): check_python() check_files() check_settings() + check_launcher() check_claude_cli() check_live_fire() check_evidence() diff --git a/tests/test_fable_doctor.py b/tests/test_fable_doctor.py index dc66974..a6c3f61 100644 --- a/tests/test_fable_doctor.py +++ b/tests/test_fable_doctor.py @@ -26,9 +26,12 @@ def fake_install(home): (claude / "hooks").mkdir(parents=True) (claude / "skills" / "fable").mkdir(parents=True) (claude / "agents").mkdir(parents=True) + (claude / "shell").mkdir(parents=True) for rel in ("FABLE_PLAYBOOK.md", "fable-code.md", "fable-system.md", "ultracode.settings.json"): (claude / rel).write_text("PLAYBOOK", encoding="utf-8") + (claude / "shell" / "fable.ps1").write_text("# stub", encoding="utf-8") + (claude / "shell" / "fable.zsh").write_text("# stub", encoding="utf-8") shutil.copy(REPO / "hooks" / "fable-trigger.py", claude / "hooks" / "fable-trigger.py") (claude / "hooks" / "test-after-edit.py").write_text("# stub", encoding="utf-8") @@ -77,3 +80,80 @@ def test_unregistered_hook_is_a_hard_failure(tmp_path): p = run_doctor(tmp_path) assert p.returncode == 1 assert "not registered for SessionStart" in p.stdout + + +def test_stale_launcher_path_is_a_hard_failure(tmp_path): + """A profile line sourcing a fable launcher that no longer exists breaks + every new shell - doctor must catch exactly this.""" + fake_install(tmp_path) + (tmp_path / ".zshrc").write_text( + '# Fable mode (added by fable-mode/install.py)\n' + 'source "/nonexistent/shell/fable.zsh"\n', encoding="utf-8") + p = run_doctor(tmp_path) + assert p.returncode == 1 + assert "launcher" in p.stdout.lower() and "missing" in p.stdout.lower() + + +def test_healthy_launcher_line_is_ok(tmp_path): + fake_install(tmp_path) + target = tmp_path / ".claude" / "shell" / "fable.zsh" + (tmp_path / ".zshrc").write_text( + 'source "{}"\n'.format(str(target).replace("\\", "\\\\")), encoding="utf-8") + p = run_doctor(tmp_path) + assert p.returncode == 0, p.stdout + + +def fake_claude_bin(tmp_path): + """A fake `claude` that answers --version and prints its flags the way the + real CLI does: the file variant only as the `[-file]` bracket shorthand.""" + bindir = tmp_path / "bin" + bindir.mkdir() + if os.name == "nt": + (bindir / "claude.cmd").write_text( + '@echo off\r\n' + 'if "%1"=="--version" echo 2.1.199 (Claude Code)\r\n' + 'if "%1"=="--help" echo --effort ^\r\n' + 'if "%1"=="--help" echo via: --append-system-prompt[-file]\r\n', + encoding="ascii") + else: + f = bindir / "claude" + f.write_text('#!/bin/sh\n' + 'case "$1" in\n' + ' --version) echo "2.1.199 (Claude Code)";;\n' + ' --help) echo " --effort ";' + ' echo " via: --append-system-prompt[-file]";;\n' + 'esac\n', encoding="ascii") + f.chmod(0o755) + return str(bindir) + + +def test_bracket_shorthand_in_help_is_not_a_missing_flag(tmp_path): + """The real CLI lists --append-system-prompt-file only as the + `--append-system-prompt[-file]` shorthand; the probe must not warn.""" + fake_install(tmp_path) + env = dict(os.environ) + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + env["PATH"] = fake_claude_bin(tmp_path) + os.pathsep + env.get("PATH", "") + env.pop("FABLE_DOCTOR_SKIP_CLI", None) + env.pop("CLAUDE_EFFORT", None) + env.pop("FABLE_MODE", None) + p = subprocess.run([sys.executable, str(DOCTOR)], capture_output=True, + text=True, env=env) + assert "claude 2.1.199" in p.stdout, p.stdout + assert "--append-system-prompt-file" not in p.stdout, ( + "bracket shorthand in --help was misread as a missing flag") + assert p.returncode == 0, p.stdout + + +def test_hooks_in_settings_local_json_count_as_registered(tmp_path): + fake_install(tmp_path) + claude = tmp_path / ".claude" + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + hooks = s.pop("hooks") + (claude / "settings.json").write_text(json.dumps(s), encoding="utf-8") + (claude / "settings.local.json").write_text(json.dumps({"hooks": hooks}), + encoding="utf-8") + p = run_doctor(tmp_path) + assert "not registered" not in p.stdout + assert p.returncode == 0, p.stdout From 106e96da00b244752ee08217880e5d6c0679c424 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:53:02 +0300 Subject: [PATCH 11/13] Remove dead personal references from the playbook; fix /fable fallback FABLE_PLAYBOOK.md is injected verbatim into sessions, yet it instructed the model to read files that only ever existed on the author's machine (~/Downloads/Fable_Mindset_public.md, ~/compare_models.py, ~/fable_dataset_delta.py, reference/llm-bias-awareness.md) - every user session risked a failing Read. They are provenance notes now, and stop-slop//code-review are no longer described as bundled. The /fable skill's fallback pointed at "this skill's repository", which does not exist next to the installed SKILL.md; it now points at fable doctor / re-running the installer. tests/test_content.py guards both. --- FABLE_PLAYBOOK.md | 36 +++++++++++++++++++----------------- skills/fable/SKILL.md | 9 ++++++--- tests/test_content.py | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 tests/test_content.py diff --git a/FABLE_PLAYBOOK.md b/FABLE_PLAYBOOK.md index 632682b..a0201c1 100644 --- a/FABLE_PLAYBOOK.md +++ b/FABLE_PLAYBOOK.md @@ -1,14 +1,15 @@ # Execution Playbook — Fable-5 patterns for Opus 4.8 (measured companion) -This is the **measured** companion to *The Fable Mindset* (`~/Downloads/Fable_Mindset_public.md`). -That document is the full disposition manual; read it first. This file does one -thing it doesn't: it puts **your** fable-5 turns next to **your** opus-4-8 turns, -head to head, so every "adopt this / refuse that" call is backed by a number -from your own history — and it is honest about where the gap was smaller than it -first looked. +This is the **measured** companion to *The Fable Mindset*, the community +disposition manual (not bundled here — nothing below depends on having it). +This file does one thing that manual doesn't: it puts real fable-5 turns next +to real opus-4-8 turns, head to head, so every "adopt this / refuse that" call +is backed by a number — and it is honest about where the gap was smaller than +it first looked. -Measured: 1,307 fable-5 turns vs 10,470 opus-4-8 turns across 139 / 243 sessions. -Re-run anytime with `~/compare_models.py`. +Measured: 1,307 fable-5 turns vs 10,470 opus-4-8 turns across 139 / 243 +sessions. The measurement tooling is not part of this bundle; the numbers are +the deliverable. --- @@ -60,8 +61,9 @@ work (mostly write-heavy greenfield builds). What it confirms and what it compli chain-of-thought on every event (100%); your Opus logs only store visible thinking blocks. Don't read a reasoning delta off these two columns. -Ruler: `~/fable_dataset_delta.py`; extracted profile: `~/fable5_dataset_profile.json`. -The 22 MB raw dataset was deleted after the metrics were extracted. +The ruler script and extracted profile are part of the measurement tooling, +not this bundle. The 22 MB raw dataset was deleted after the metrics were +extracted. --- @@ -203,8 +205,8 @@ makes the work correct; this layer makes the output read like Fable wrote it. - **Own mistakes without grovelling.** When wrong, acknowledge it plainly, fix it, stay on the problem — no self-abasement, no excessive apology, no caving on a correct position just because you were challenged. Maintain self-respect; the - goal is steady honest helpfulness. (Pairs with `reference/llm-bias-awareness.md`: - a challenge is not an automatic signal to surrender.) + goal is steady honest helpfulness. A challenge is not an automatic signal to + surrender. - **Epistemic honesty / no confabulation.** Partial recognition from training is *not* current knowledge. For any library, version, product, or fact that may have moved, verify (Context7 / web / the file itself) rather than answering from @@ -323,17 +325,17 @@ levers the harness actually enforces: that runs the project's test command, with `hooksEnabled: true`. This is the fix for Fix 1; it fires every time, not most of the time. - **Voice/formatting** → not hook-enforceable; it is pure disposition. The closest - lever is the bundled `stop-slop` skill — invoke it for any substantial prose - (docs, PRs, commit messages, user-facing summaries). For everything else the - voice-layer section above is the standing rule. + lever is a `stop-slop` skill, if one is installed — invoke it for any substantial + prose (docs, PRs, commit messages, user-facing summaries). For everything else + the voice-layer section above is the standing rule. - **Grounding / verification independence** → the protocol ships as the `/ground` skill (`~/.claude/skills/ground/`, invoke by name for the full self-terminating ledger loop) and the cold `grounding-verifier` agent (`~/.claude/agents/`, spawn for an independent read-only check that never sees your reasoning). Fable mode loads the Grounding section above automatically via this playbook, so the standing discipline is always on; the skill and agent are the heavier, explicit forms. For code changes - the bundled `/code-review` + `/verify` skills run alongside the verifier. Never - self-approve in the same active context. + Claude Code's built-in `/code-review` + `/verify` skills run alongside the verifier. + Never self-approve in the same active context. - **Placement** → these rules belong in a `CLAUDE.md` (loads every session), not auto-memory (relevance-gated, may not surface on a given turn). Point sessions at this file and *The Fable Mindset* deliberately; wire the hook and effort diff --git a/skills/fable/SKILL.md b/skills/fable/SKILL.md index ee49900..16460cd 100644 --- a/skills/fable/SKILL.md +++ b/skills/fable/SKILL.md @@ -9,11 +9,14 @@ Activate the full Fable discipline for the rest of this session: 1. Read `~/.claude/FABLE_PLAYBOOK.md` (execution discipline: reason before acting, observe-then-decide, verify every edit, communication floor, - grounding protocol). If the file is missing, read `FABLE_PLAYBOOK.md` from - this skill's repository instead. + grounding protocol). 2. Read `~/.claude/fable-code.md` (the Claude Code behavior layer: final-message contract, readable-over-concise, tool discipline, autonomy and honesty - rules). Same fallback. + rules). + If either file is missing, the install is broken — don't hunt for other + copies. Tell the user to run `fable doctor` (or re-run `python install.py` + from the fable-mode checkout), then continue with the task, applying + whatever Fable discipline is already in context. 3. Adopt both as standing discipline — they govern every subsequent turn of this session, not just the next reply. 4. Confirm activation in one line ("Fable mode active — playbook and behavior diff --git a/tests/test_content.py b/tests/test_content.py new file mode 100644 index 0000000..59bd389 --- /dev/null +++ b/tests/test_content.py @@ -0,0 +1,25 @@ +"""Guards on shipped prompt/skill content: the files injected into every +session must not point at files that do not exist on a user's machine.""" +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def test_playbook_has_no_dead_personal_references(): + """The playbook is injected verbatim; an instruction to read a file that + only ever existed on the author's machine sends every user's model on a + failing Read. Provenance may be described, not pointed at.""" + text = (REPO / "FABLE_PLAYBOOK.md").read_text(encoding="utf-8") + for needle in ("~/Downloads", "compare_models.py", "fable_dataset_delta.py", + "fable5_dataset_profile.json", "llm-bias-awareness.md", + "bundled `stop-slop`", "bundled `/code-review`"): + assert needle not in text, f"dead reference in FABLE_PLAYBOOK.md: {needle}" + + +def test_fable_skill_fallback_is_reachable(): + """After install the skill is a lone SKILL.md under ~/.claude/skills/fable; + 'this skill's repository' does not exist there. The recovery path a user + can actually take is `fable doctor` / re-running the installer.""" + text = (REPO / "skills" / "fable" / "SKILL.md").read_text(encoding="utf-8") + assert "this skill's repository" not in text + assert "fable doctor" in text From df6286b688c282ed93b8fe9e9aff4d08c4c41675 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 10:53:02 +0300 Subject: [PATCH 12/13] Document the hardening pass in README and CHANGELOG --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 444fdae..4f0e0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- The shell profile sourced the launcher from the cloned repo, so deleting or + moving the clone broke every new shell, and a re-install from a new path + silently kept the stale line. The installer now copies `fable.ps1`/`fable.zsh` + into `~/.claude/shell/` and sources the stable copy; a stale launcher line is + replaced, not kept. +- Auto/effort-activated sessions lost the playbook forever after a compaction: + SessionStart re-injected only under `FABLE_MODE=1`, while the session marker + blocked the UserPromptSubmit path. The trigger hook now re-injects on + `source == "compact"` whenever the marker proves the mode was active, and + drops the marker on `source == "clear"` so the once-per-session paths re-arm. +- `FABLE_PLAYBOOK.md` instructed the model to read the author's personal files + (`~/Downloads/Fable_Mindset_public.md`, `~/compare_models.py`, + `~/fable_dataset_delta.py`, `reference/llm-bias-awareness.md`) that no user + has — every fable session risked a failing Read. They are now provenance + notes, not paths; a content-guard test keeps them out. +- The `/fable` skill's fallback ("read the playbook from this skill's + repository") was unreachable after install — the installed skill is a lone + SKILL.md. The fallback now points at `fable doctor` / re-running the + installer. +- The installer silently `rmtree`'d a user's own `~/.claude/skills/` + when it collided with a bundled skill name; it now preserves such + directories under `~/.claude/backups/skills/` and marks its own copies with + a `.fable-mode-bundled` file. The uninstaller removes only marked + directories. +- `test-after-edit.py` ran the suite even when the Edit tool itself had + failed (reporting a stale result), and `fable.ps1 doctor` hardcoded + `python`, breaking on machines where only `py`/`python3` exists. Both + fixed; the doctor subcommand now falls back across `python`/`py`/`python3`. +- Session/debounce marker files accumulated in `%TEMP%` forever on Windows; + both hooks now garbage-collect markers older than a week. - "Invalid JSON provided for --settings" on Windows PowerShell 5.1 (#2): PS 5.1 strips embedded quotes when building the native command line, so the inline `--settings '{"ultracode": true}'` reached claude as `{ultracode: true}`. The @@ -34,6 +64,20 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `.lockb` skip entry. ### Added +- Double-injection guard: before the once-per-session auto/effort injection, + the trigger hook scans the session transcript for a "Fable mode active" + line — a session already activated via the `/fable` skill doesn't get a + second copy of the playbook. +- On Windows the launcher is now installed into the `$PROFILE` of **both** + PowerShell 7 and Windows PowerShell 5.1 when both are present. +- `fable doctor` now also verifies the launcher line in the shell profiles + (a line pointing at a missing file is a hard failure), accepts hooks + registered in `settings.local.json`, probes `claude --help` for the + `--effort` / `--append-system-prompt-file` flags the launcher relies on, + and checks the installed `~/.claude/shell/` launcher copies. +- The auto-activation heuristic learned more everyday task verbs + (update/upgrade/remove/delete/deploy/rename/write; удалить/обновить/ + написать/запустить/перенести/убрать). - Auto-activation: `fable-trigger.py` scores prompt complexity (ru+en signals: task verbs, code fences, file paths, multi-step markers, length) and loads the playbook by itself for task-shaped prompts, once per session, in any diff --git a/README.md b/README.md index 4a3bd37..0417efd 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Prefer a native one-liner? `./install.sh` (macOS / Linux) and `.\install.ps1` (W > **Requirements:** Python on PATH (`python --version`) for the hooks, and Claude Code installed for the `fable` launcher. On Windows, if `.\install.ps1` is blocked by execution policy, run `python install.py` directly (no policy needed). -The installer copies everything into `~/.claude`, adds the `fable` launcher (to your shell rc on Unix, to your PowerShell `$PROFILE` on Windows), and merges your settings (with a backup) — writing the absolute interpreter and hook paths so the hooks fire on every platform. Idempotent: safe to re-run. Needs Python ≥ 3.9 (the hooks are stdlib-only — no pip installs). No model switch, no API key — it runs on the Opus 4.8 you already have. +The installer copies everything into `~/.claude` — including the launcher itself (`~/.claude/shell/`), so you can delete the clone afterwards — adds the `fable` launcher (to your shell rc on Unix, to the `$PROFILE` of both PowerShell 7 and Windows PowerShell on Windows), and merges your settings (with a backup) — writing the absolute interpreter and hook paths so the hooks fire on every platform. Idempotent: safe to re-run, and a same-named skill you wrote yourself is preserved under `~/.claude/backups/skills/` instead of being overwritten. Needs Python ≥ 3.9 (the hooks are stdlib-only — no pip installs). No model switch, no API key — it runs on the Opus 4.8 you already have. ## Uninstall @@ -56,7 +56,7 @@ The installer copies everything into `~/.claude`, adds the `fable` launcher (to python uninstall.py # Windows (python3 on macOS / Linux; or ./uninstall.sh, .\uninstall.ps1) ``` -Removes the bundled files from `~/.claude`, strips the `fable` launcher line, and drops the two Fable hooks from `settings.json` (writing a fresh backup). It's surgical — your own skills, unrelated hooks, and `alwaysThinkingEnabled` are left untouched. +Removes the bundled files from `~/.claude`, strips the `fable` launcher line, and drops the two Fable hooks from `settings.json` (writing a fresh backup). It's surgical — only skill directories carrying the installer's `.fable-mode-bundled` marker are removed; your own skills, unrelated hooks, and `alwaysThinkingEnabled` are left untouched. ## What's in the bundle From d9032c3db0ed1a8ef74a6c44c84d986023b86717 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 3 Jul 2026 11:04:49 +0300 Subject: [PATCH 13/13] fable doctor: spawn .cmd shims through cmd.exe in the CLI probe npm installs the claude CLI as a .cmd shim on Windows, and CreateProcess cannot spawn those by bare name or path - the probe failed with WinError 2 on such installs (and on CI, where the fake test CLI is a .cmd on purpose). The probe now resolves the executable via shutil.which and routes .cmd/.bat through cmd.exe. The fake CLI in the test also reports a deliberately unreal version (2.1.777) so a pass proves the fake answered, not a claude that happens to be installed on the host. --- hooks/fable-doctor.py | 15 ++++++++++++--- tests/test_fable_doctor.py | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/hooks/fable-doctor.py b/hooks/fable-doctor.py index a8c695e..fb8dbae 100644 --- a/hooks/fable-doctor.py +++ b/hooks/fable-doctor.py @@ -164,15 +164,24 @@ def check_launcher(): "(re-run install.py to add it)") +def _shim_safe(exe, args): + """npm installs claude as a .cmd shim on Windows; CreateProcess can't + spawn those by name or path — route them through cmd.exe.""" + if exe.lower().endswith((".cmd", ".bat")): + return ["cmd", "/c", exe] + args + return [exe] + args + + def check_claude_cli(): if os.environ.get("FABLE_DOCTOR_SKIP_CLI") == "1": warn("claude CLI probe skipped (FABLE_DOCTOR_SKIP_CLI=1)") return - if not shutil.which("claude"): + exe = shutil.which("claude") + if not exe: warn("claude CLI not on PATH - the fable launcher won't start") return try: - out = subprocess.run(["claude", "--version"], capture_output=True, + out = subprocess.run(_shim_safe(exe, ["--version"]), capture_output=True, text=True, timeout=30).stdout.strip() except Exception as e: warn("claude --version failed: {}".format(e)) @@ -189,7 +198,7 @@ def check_claude_cli(): warn("claude {} < 2.1.199: effort not exposed to hooks; " "phrase/FABLE_MODE/SessionStart paths unaffected".format(label)) try: - help_out = subprocess.run(["claude", "--help"], capture_output=True, + help_out = subprocess.run(_shim_safe(exe, ["--help"]), capture_output=True, text=True, timeout=30).stdout except Exception: help_out = "" diff --git a/tests/test_fable_doctor.py b/tests/test_fable_doctor.py index a6c3f61..dea44bd 100644 --- a/tests/test_fable_doctor.py +++ b/tests/test_fable_doctor.py @@ -105,13 +105,17 @@ def test_healthy_launcher_line_is_ok(tmp_path): def fake_claude_bin(tmp_path): """A fake `claude` that answers --version and prints its flags the way the - real CLI does: the file variant only as the `[-file]` bracket shorthand.""" + real CLI does: the file variant only as the `[-file]` bracket shorthand. + On Windows it is a .cmd shim on purpose — that is exactly how npm installs + the real CLI, and spawning it is part of what the probe must handle. The + version is deliberately not a real release so a pass proves the fake (not + a claude installed on the host) answered.""" bindir = tmp_path / "bin" bindir.mkdir() if os.name == "nt": (bindir / "claude.cmd").write_text( '@echo off\r\n' - 'if "%1"=="--version" echo 2.1.199 (Claude Code)\r\n' + 'if "%1"=="--version" echo 2.1.777 (Claude Code)\r\n' 'if "%1"=="--help" echo --effort ^\r\n' 'if "%1"=="--help" echo via: --append-system-prompt[-file]\r\n', encoding="ascii") @@ -119,7 +123,7 @@ def fake_claude_bin(tmp_path): f = bindir / "claude" f.write_text('#!/bin/sh\n' 'case "$1" in\n' - ' --version) echo "2.1.199 (Claude Code)";;\n' + ' --version) echo "2.1.777 (Claude Code)";;\n' ' --help) echo " --effort ";' ' echo " via: --append-system-prompt[-file]";;\n' 'esac\n', encoding="ascii") @@ -127,9 +131,11 @@ def fake_claude_bin(tmp_path): return str(bindir) -def test_bracket_shorthand_in_help_is_not_a_missing_flag(tmp_path): - """The real CLI lists --append-system-prompt-file only as the - `--append-system-prompt[-file]` shorthand; the probe must not warn.""" +def test_cli_probe_handles_cmd_shims_and_bracket_shorthand(tmp_path): + """Two probe requirements in one live run: a .cmd shim on PATH (the npm + install layout on Windows) must be spawnable, and the real CLI's + `--append-system-prompt[-file]` bracket shorthand must not be misread as + a missing flag.""" fake_install(tmp_path) env = dict(os.environ) env["HOME"] = str(tmp_path) @@ -140,7 +146,7 @@ def test_bracket_shorthand_in_help_is_not_a_missing_flag(tmp_path): env.pop("FABLE_MODE", None) p = subprocess.run([sys.executable, str(DOCTOR)], capture_output=True, text=True, env=env) - assert "claude 2.1.199" in p.stdout, p.stdout + assert "claude 2.1.777" in p.stdout, p.stdout assert "--append-system-prompt-file" not in p.stdout, ( "bracket shorthand in --help was misread as a missing flag") assert p.returncode == 0, p.stdout