Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<marketplace>/<plugin>/<version>`, 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.
Expand Down
36 changes: 35 additions & 1 deletion hooks/gate_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from __future__ import annotations

import json
import os
import sys
from pathlib import Path

Expand All @@ -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 "")
Expand All @@ -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,
}
}
)
Expand Down
31 changes: 27 additions & 4 deletions setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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."
Expand Down
152 changes: 152 additions & 0 deletions tests/test_setup_paths.py
Original file line number Diff line number Diff line change
@@ -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/<marketplace>/<plugin>/<version>. 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())