diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f59cd8a..e19a968 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -3,7 +3,7 @@ "owner": { "name": "fivetaku", "email": "gptaku.ai@gmail.com" }, "metadata": { "description": "fablize — a harness that makes Opus behave like Fable (completion, evidence, verification as procedure)", - "version": "2.1.2" + "version": "2.1.3" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d0c3ce2..42cc294 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fablize", - "version": "2.1.2", + "version": "2.1.3", "description": "A harness that makes Opus (or any Claude model) behave like Fable. It enforces completion, evidence, and verification as procedure, and auto-routes the right verified pack per task: render-output verification, a multi-story evidence gate, an investigation protocol, and an early-stop guard. It does not fake model capability — see README for the full analysis of what transfers and what does not.", "author": { "name": "fivetaku", "email": "gptaku.ai@gmail.com" }, "keywords": ["harness", "verification", "completion", "opus", "fable", "agentic"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 9482eca..234d4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to fablize are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer](https://semver.org/). +## [2.1.3] — 2026-08-03 + +Fork release (`cakel/fablize`). + +### Fixed + +- **The injected CLAUDE.md block no longer bakes a version-pinned path** + (`setup/setup.sh`). It substituted `$CLAUDE_PLUGIN_ROOT`, which is + `.../cache///`, so after a plugin upgrade every + path in the block pointed at a directory that no longer exists — with nothing + to notice or repair it. Observed live: the block still pointed at `2.1.1` + after `2.1.2` was installed. Setup now copies `scripts/goals.py` and + `packs/*.txt` to `~/.fablize/lib/` and injects that stable path. The hooks + were never affected; they resolve `${CLAUDE_PLUGIN_ROOT}` at load time. +- **The recorded setup version is read from the manifest** (`setup/setup.sh`), + not a string literal that silently disagreed with the installed plugin after + every release. + +### Added + +- **Upgrade staleness notice** (`hooks/gate_prompt.py`). `progress.json`'s + `version` was written and never read. `stale_setup_notice()` now compares it + against the running plugin's manifest and prepends one line when they differ, + pointing at `/fablize:setup` — the only thing that can refresh the copies and + the CLAUDE.md block. Silent when they match or anything is unreadable. +- `tests/test_setup_paths.py` — 17 checks driving the real `setup.sh` against a + temp HOME and a fake versioned plugin root: no version in the injected block, + every referenced asset exists, assets refresh on re-run after an upgrade, the + block is not duplicated, and the notice fires only on a mismatch. + ## [2.1.2] — 2026-08-03 Fork release (`cakel/fablize`). Gate-accuracy fixes only; no behaviour added. diff --git a/hooks/gate_prompt.py b/hooks/gate_prompt.py index cc03c4c..695f2ab 100755 --- a/hooks/gate_prompt.py +++ b/hooks/gate_prompt.py @@ -7,6 +7,8 @@ from __future__ import annotations +import json +import os import sys from pathlib import Path @@ -16,6 +18,33 @@ from classify_task import classify_prompt, context_for_mode +def stale_setup_notice() -> str: + """One line when the installed plugin is newer than what setup recorded. + + setup.sh copies goals.py and the packs to ~/.fablize/lib and writes the + version it copied. A plugin upgrade replaces the code but cannot rewrite + CLAUDE.md or refresh those copies, so without this the block silently keeps + pointing at last release's assets. Any error here means no notice at all. + """ + try: + running = json.loads( + (Path(__file__).resolve().parent.parent / ".claude-plugin" / "plugin.json") + .read_text(encoding="utf-8") + )["version"] + recorded = json.loads( + Path(os.path.expanduser("~/.fablize/progress.json")).read_text(encoding="utf-8") + ).get("version") + except Exception: # noqa: BLE001 — never let this block a prompt + return "" + if not recorded or recorded == running: + return "" + return ( + f"fablize was upgraded ({recorded} -> {running}) but setup has not re-run, so the " + "CLAUDE.md block still points at the previous release's copies. " + "Re-run `/fablize:setup` to refresh them." + ) + + def main() -> int: input_data = read_stdin_json() prompt = str(input_data.get("prompt") or input_data.get("user_prompt") or "") @@ -34,11 +63,16 @@ def apply(ledger): update_ledger(input_data, apply) + context = context_for_mode(mode, risks) + notice = stale_setup_notice() + if notice: + context = notice + "\n" + context + emit_json( { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", - "additionalContext": context_for_mode(mode, risks), + "additionalContext": context, } } ) diff --git a/setup/setup.sh b/setup/setup.sh index e33609e..6bbf5b4 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -124,8 +124,20 @@ mkdir -p "$(dirname "$CLAUDE_MD")"; touch "$CLAUDE_MD" ts=$(python3 -c "import time;print(int(time.time()))") cp "$CLAUDE_MD" "$CLAUDE_MD.fablize-bak.$ts" && echo " backup: $CLAUDE_MD.fablize-bak.$ts" -# Substitute __PLUGIN_ROOT__ -> real path, then inject idempotently (remove old markers, re-insert). -python3 - "$CLAUDE_MD" "$BLOCK_TPL" "$ROOT" <<'PY' +# The block used to bake $ROOT — a VERSION-PINNED cache path like +# .../cache/fablize/fablize/2.1.1 — straight into CLAUDE.md. After a plugin +# upgrade that directory is gone and every path in the injected block dangles, +# with nothing to notice or repair it. Copy the referenced assets to a stable +# location instead and point the block there, so the paths survive upgrades. +# The hooks keep using ${CLAUDE_PLUGIN_ROOT}; only this text needed pinning. +LIB_DIR="$STATE_DIR/lib" +mkdir -p "$LIB_DIR/scripts" "$LIB_DIR/packs" +cp "$ROOT/scripts/goals.py" "$LIB_DIR/scripts/goals.py" +cp "$ROOT"/packs/*.txt "$LIB_DIR/packs/" +echo " ✓ assets: $LIB_DIR (refreshed from $ROOT)" + +# Substitute __PLUGIN_ROOT__ -> stable path, then inject idempotently (remove old markers, re-insert). +python3 - "$CLAUDE_MD" "$BLOCK_TPL" "$LIB_DIR" <<'PY' import sys, re, pathlib md, tpl, root = sys.argv[1], sys.argv[2], sys.argv[3] p = pathlib.Path(md) @@ -138,10 +150,21 @@ PY # Record setup state so the skill won't auto-run setup again. mkdir -p "$STATE_DIR" -python3 - "$scope" "$ts" <<'PY' +python3 - "$scope" "$ts" "$ROOT" <<'PY' import json, sys, os +# Read the version from the manifest instead of a literal: a hardcoded string +# here silently disagrees with the installed plugin after every release, and the +# staleness check in gate_prompt.py compares against exactly this value. +root = sys.argv[3] +try: + version = json.load(open(os.path.join(root, ".claude-plugin", "plugin.json"), + encoding="utf-8"))["version"] +except Exception: + version = "unknown" p = os.path.expanduser("~/.fablize/progress.json") -json.dump({"setup_done": True, "scope": sys.argv[1], "version": "2.1.2", "ts": int(sys.argv[2])}, open(p, "w")) +json.dump({"setup_done": True, "scope": sys.argv[1], "version": version, "ts": int(sys.argv[2])}, + open(p, "w")) +print(f" ✓ state: version {version} recorded") PY echo "fablize setup complete ($scope) — applies from the next session." diff --git a/tests/test_setup_paths.py b/tests/test_setup_paths.py new file mode 100644 index 0000000..84cf465 --- /dev/null +++ b/tests/test_setup_paths.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""setup.sh must not bake a version-pinned path into CLAUDE.md. + +The injected block used to carry $CLAUDE_PLUGIN_ROOT, which is +.../cache///. After an upgrade that directory is +gone and every path in the block dangles, with nothing to notice or repair it — +observed live: the block pointed at 2.1.1 while 2.1.2 was installed. + +Drives the real setup.sh against a temp HOME and a fake plugin root, then checks +the injected block, the copied assets, and the recorded version. Also exercises +gate_prompt.stale_setup_notice() both ways. Exit non-zero on any mismatch. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "hooks")) + +CHECKS = [] + + +def check(label, got, want): + CHECKS.append((label, got, want)) + + +def fake_plugin_root(base: Path, version: str) -> Path: + """A plugin tree laid out like the real versioned cache directory.""" + root = base / "cache" / "fablize" / "fablize" / version + (root / "scripts").mkdir(parents=True) + (root / "packs").mkdir() + (root / "setup").mkdir() + (root / ".claude-plugin").mkdir() + (root / "scripts" / "goals.py").write_text(f"# goals {version}\n", encoding="utf-8") + # Mirror the real pack set so "every referenced asset exists" is meaningful. + for pack in (REPO / "packs").glob("*.txt"): + (root / "packs" / pack.name).write_text(f"{pack.name} {version}\n", encoding="utf-8") + (root / ".claude-plugin" / "plugin.json").write_text( + json.dumps({"name": "fablize", "version": version}), encoding="utf-8" + ) + shutil.copy(REPO / "setup" / "setup.sh", root / "setup" / "setup.sh") + shutil.copy(REPO / "setup" / "fablize-block.md", root / "setup" / "fablize-block.md") + return root + + +def run_setup(root: Path, home: Path) -> subprocess.CompletedProcess: + env = dict(os.environ) + # Redirect every home-ish variable: bash reads HOME, but Python's + # expanduser() on Windows prefers USERPROFILE, then HOMEDRIVE+HOMEPATH. + env.update(HOME=str(home), USERPROFILE=str(home), + CLAUDE_PLUGIN_ROOT=str(root), CLAUDE_CONFIG_DIR=str(home / ".claude")) + for key in ("HOMEDRIVE", "HOMEPATH"): + env.pop(key, None) + # encoding= explicitly: the console codepage (cp949 here) cannot decode the + # script's ✓ output, and the default would raise instead of running. + return subprocess.run( + ["bash", str(root / "setup" / "setup.sh"), "global"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env=env, cwd=str(root), timeout=120, + ) + + +with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + home = tmp / "home" + (home / ".claude").mkdir(parents=True) + root = fake_plugin_root(tmp, "9.9.9") + + proc = run_setup(root, home) + check("setup.sh exit code", proc.returncode, 0) + if proc.returncode != 0: + print("--- setup.sh stdout ---\n" + proc.stdout) + print("--- setup.sh stderr ---\n" + proc.stderr) + raise SystemExit(1) + + claude_md = (home / ".claude" / "CLAUDE.md").read_text(encoding="utf-8") + lib = home / ".fablize" / "lib" + + # bash normalizes to forward slashes; compare in that form. + lib_fwd = str(lib).replace("\\", "/") + + check("block injected", "FABLIZE:BEGIN" in claude_md, True) + check("no versioned cache path in block", + bool(re.search(r"cache[/\\]fablize[/\\]fablize[/\\]\d", claude_md)), False) + check("block points at stable lib dir", lib_fwd in claude_md, True) + check("goals.py copied", (lib / "scripts" / "goals.py").exists(), True) + check("packs copied", (lib / "packs" / "investigation-protocol.txt").exists(), True) + check("copied goals.py is this version", + (lib / "scripts" / "goals.py").read_text(encoding="utf-8").strip(), "# goals 9.9.9") + + progress = json.loads((home / ".fablize" / "progress.json").read_text(encoding="utf-8")) + check("recorded version comes from manifest", progress.get("version"), "9.9.9") + + # Every path the block names must exist — the whole point of the fix. + referenced = re.findall(re.escape(lib_fwd) + r"[^\s`]*", claude_md) + check("block names at least one asset", len(referenced) > 0, True) + check("every referenced asset exists", + sorted({p for p in referenced if not Path(p).exists()}), []) + + # Re-running after an upgrade must refresh the copies in place. + root2 = fake_plugin_root(tmp, "9.9.10") + proc2 = run_setup(root2, home) + check("re-run exit code", proc2.returncode, 0) + check("assets refreshed on upgrade", + (lib / "scripts" / "goals.py").read_text(encoding="utf-8").strip(), "# goals 9.9.10") + claude_md2 = (home / ".claude" / "CLAUDE.md").read_text(encoding="utf-8") + check("block still version-free", + bool(re.search(r"cache[/\\]fablize[/\\]fablize[/\\]\d", claude_md2)), False) + check("block not duplicated", claude_md2.count("FABLIZE:BEGIN"), 1) + + # staleness notice: recorded version behind the running plugin + import gate_prompt + + progress_path = home / ".fablize" / "progress.json" + real_expanduser = os.path.expanduser + os.path.expanduser = lambda p: (str(progress_path) if p == "~/.fablize/progress.json" + else real_expanduser(p)) + try: + running = json.loads((REPO / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))["version"] + progress_path.write_text(json.dumps({"version": "0.0.1"}), encoding="utf-8") + check("notice when versions differ", "0.0.1" in gate_prompt.stale_setup_notice(), True) + progress_path.write_text(json.dumps({"version": running}), encoding="utf-8") + check("silent when versions match", gate_prompt.stale_setup_notice(), "") + progress_path.write_text("{not json", encoding="utf-8") + check("silent when progress unreadable", gate_prompt.stale_setup_notice(), "") + finally: + os.path.expanduser = real_expanduser + + +def main(): + bad = 0 + for label, got, want in CHECKS: + ok = got == want + if not ok: + bad += 1 + print(f"{'OK ' if ok else 'FAIL'} {str(got)[:40]:<42} want={str(want)[:24]:<26} {label}") + print("-" * 78) + if bad: + print(f"RESULT: {bad}/{len(CHECKS)} mismatched.") + return 1 + print(f"RESULT: all {len(CHECKS)} checks match.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())