← Files get-fableARCHIVED FILE
hooks/_fable_common.py
18.7 KB · Oct 2, 2026 · 00:30 UTC
"""Shared helpers for get-fable lifecycle hooks.
Safety contract:
- FAIL OPEN: an unexpected hook error must not brick the host session.
- OPT IN: hooks act only inside a project with a `.fable/` directory.
- PROJECT LOCAL: durable workflow state stays in `.fable/state.json`.
"""
import datetime
import hashlib
import json
import math
import os
import re
import sys
import tempfile
import time
STATE_SCHEMA_VERSION = 3
from _fable_catalog import CANONICAL_SKILLS
PHASES = {
"idle",
"discovering",
"planned",
"executing",
"verifying",
"recovering",
"complete",
"blocked",
}
EVIDENCE_KINDS = {
"test",
"build",
"runtime",
"review",
"observation",
"security",
"research",
"receipt",
"handoff",
}
BEHAVIOR_COMPLETION_EVIDENCE_KINDS = {
"test",
"build",
"runtime",
"review",
"observation",
}
FABLE_PACKS = {
"core",
"intelligence",
"build",
"proof",
"delivery",
"evolution",
"system",
"creator",
}
TASK_SHAPES = {
"research",
"architecture",
"bug-fix",
"feature",
"delegation",
"review",
"security",
"release",
"handoff",
"eval",
"bounded-change",
"unknown",
}
FAILURE_RELEVANT_EVIDENCE_KINDS = BEHAVIOR_COMPLETION_EVIDENCE_KINDS | {"security"}
def read_hook_input():
"""Parse hook JSON delivered on stdin. Return {} on any problem."""
try:
raw = sys.stdin.read()
if not raw.strip():
return {}
value = json.loads(raw)
return value if isinstance(value, dict) else {}
except Exception:
return {}
def start_dir(data):
"""Best-effort project directory from hook cwd, else process cwd."""
cwd = data.get("cwd")
if cwd and os.path.isdir(cwd):
return cwd
try:
return os.getcwd()
except Exception:
return "."
def find_fable_dir(start):
"""Walk upward for `.fable/`, stopping at the project git root."""
try:
cur = os.path.abspath(start)
except Exception:
return None
while True:
candidate = os.path.join(cur, ".fable")
if os.path.isdir(candidate):
return candidate
if os.path.isdir(os.path.join(cur, ".git")):
return None
parent = os.path.dirname(cur)
if parent == cur:
return None
cur = parent
def ledger_path(fable_dir):
return os.path.join(fable_dir, "LEDGER.md")
def state_path(fable_dir):
return os.path.join(fable_dir, "state.json")
def now_iso():
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
def workspace_id(fable_dir):
project_dir = os.path.realpath(os.path.abspath(os.path.dirname(fable_dir)))
return hashlib.sha256(project_dir.encode("utf-8", errors="surrogatepass")).hexdigest()[:24]
def _valid_nonempty_string(value):
return isinstance(value, str) and bool(value.strip())
def _valid_string_list(value):
return isinstance(value, list) and all(_valid_nonempty_string(item) for item in value)
def _valid_routing_decision(value):
if not isinstance(value, dict):
return False
selected_skill = value.get("selectedSkill")
if selected_skill not in CANONICAL_SKILLS:
return False
if value.get("selectedPack") not in FABLE_PACKS:
return False
if value.get("taskShape") not in TASK_SHAPES:
return False
confidence = value.get("confidence")
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(confidence):
return False
if confidence < 0 or confidence > 1:
return False
if not _valid_string_list(value.get("reasons")):
return False
if not isinstance(value.get("requiresPlan"), bool):
return False
if not _valid_string_list(value.get("requiredGates")):
return False
fallback_skill = value.get("fallbackSkill")
if fallback_skill is not None and fallback_skill not in CANONICAL_SKILLS:
return False
parallel_candidates = value.get("parallelCandidates")
if not isinstance(parallel_candidates, list) or any(skill not in CANONICAL_SKILLS for skill in parallel_candidates):
return False
next_skills = value.get("nextSkills")
if not isinstance(next_skills, list) or any(skill not in CANONICAL_SKILLS for skill in next_skills):
return False
scores = value.get("scores")
if not isinstance(scores, dict):
return False
for skill in CANONICAL_SKILLS:
score = scores.get(skill)
if (
isinstance(score, bool)
or not isinstance(score, (int, float))
or not math.isfinite(score)
or score < 0
):
return False
return True
def _valid_evidence(record, max_generation, owner_workspace_id):
if not isinstance(record, dict):
return False
if record.get("kind") not in EVIDENCE_KINDS:
return False
if record.get("result") not in ("pass", "fail"):
return False
if not _valid_nonempty_string(record.get("source")):
return False
if not _valid_nonempty_string(record.get("detail")):
return False
if not _valid_nonempty_string(record.get("timestamp")):
return False
record_workspace_id = record.get("workspaceId")
if record_workspace_id is not None and record_workspace_id != owner_workspace_id:
return False
generation = record.get("generation")
return isinstance(generation, int) and 0 <= generation <= max_generation
def _security_task(state):
if not isinstance(state, dict):
return False
decision = state.get("lastDecision")
if "lastDecision" in state and decision is not None:
if not _valid_routing_decision(decision):
return False
return (
decision.get("selectedSkill") == "fable-security"
and decision.get("selectedPack") == "proof"
and decision.get("taskShape") == "security"
)
return state.get("currentSkill") == "fable-security"
def completion_evidence_kinds(state):
kinds = set(BEHAVIOR_COMPLETION_EVIDENCE_KINDS)
if _security_task(state):
kinds.add("security")
return kinds
def _legacy_completion_evidence_kinds(state):
"""Derive migration scope only from canonical skill identities."""
decision = state.get("lastDecision")
selected_skill = decision.get("selectedSkill") if isinstance(decision, dict) else None
migration_context = {
"currentSkill": state.get("currentSkill") if state.get("currentSkill") in CANONICAL_SKILLS else None,
"lastDecision": (
{"selectedSkill": selected_skill}
if selected_skill in CANONICAL_SKILLS
else None
),
}
return completion_evidence_kinds(migration_context)
def _migrate_v1_state(fable_dir, state):
evidence = state.get("evidence")
if not isinstance(evidence, list):
return None
owner_workspace_id = workspace_id(fable_dir)
migrated_evidence = []
for record in evidence:
if not isinstance(record, dict):
return None
migrated = dict(record)
migrated["generation"] = 0
migrated_evidence.append(migrated)
accepted_completion_kinds = _legacy_completion_evidence_kinds(state)
latest_completion = None
for record in reversed(migrated_evidence):
if record.get("kind") in accepted_completion_kinds:
latest_completion = record
break
migrated = dict(state)
migrated["schemaVersion"] = STATE_SCHEMA_VERSION
migrated["stateRevision"] = 0
migrated["workspaceId"] = owner_workspace_id
migrated["mutationGeneration"] = 0
migrated["verifiedGeneration"] = (
0
if latest_completion
and latest_completion.get("workspaceId") == owner_workspace_id
and latest_completion.get("result") == "pass"
else -1
)
migrated["activeCard"] = None
migrated["evidence"] = migrated_evidence
return migrated
def _migrate_v2_state(fable_dir, state):
if state.get("workspaceId") != workspace_id(fable_dir):
return None
migrated = dict(state)
migrated["schemaVersion"] = STATE_SCHEMA_VERSION
migrated["stateRevision"] = 0
return migrated
def read_state(fable_dir):
"""Read valid schema-v3 runtime state, migrating schema v1/v2 in memory."""
try:
with open(state_path(fable_dir), encoding="utf-8") as handle:
state = json.load(handle)
if not isinstance(state, dict):
return None
if state.get("schemaVersion") == 1:
state = _migrate_v1_state(fable_dir, state)
if state is None:
return None
elif state.get("schemaVersion") == 2:
state = _migrate_v2_state(fable_dir, state)
if state is None:
return None
if state.get("schemaVersion") != STATE_SCHEMA_VERSION:
return None
state_revision = state.get("stateRevision")
if not isinstance(state_revision, int) or state_revision < 0:
return None
if not _valid_nonempty_string(state.get("workspaceId")):
return None
if state.get("workspaceId") != workspace_id(fable_dir):
return None
if state.get("phase") not in PHASES:
return None
skill = state.get("currentSkill")
if skill is not None and skill not in CANONICAL_SKILLS:
return None
streak = state.get("failureStreak")
if not isinstance(streak, int) or streak < 0:
return None
if not isinstance(state.get("substantial"), bool):
return None
mutation_generation = state.get("mutationGeneration")
verified_generation = state.get("verifiedGeneration")
if not isinstance(mutation_generation, int) or mutation_generation < 0:
return None
if not isinstance(verified_generation, int) or verified_generation < -1 or verified_generation > mutation_generation:
return None
active_card = state.get("activeCard")
if active_card is not None and not _valid_nonempty_string(active_card):
return None
evidence = state.get("evidence")
if not isinstance(evidence, list):
return None
owner_workspace_id = state.get("workspaceId")
if any(not _valid_evidence(record, mutation_generation, owner_workspace_id) for record in evidence):
return None
return state
except Exception:
return None
def write_state(fable_dir, state):
"""Atomically write state beside the current state file. Best effort."""
if not isinstance(state, dict):
return False
try:
os.makedirs(fable_dir, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".state.", suffix=".tmp", dir=fable_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, state_path(fable_dir))
return True
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
except Exception:
return False
def has_fresh_passing_state_evidence(state):
if not isinstance(state, dict):
return False
mutation_generation = state.get("mutationGeneration")
verified_generation = state.get("verifiedGeneration")
if not isinstance(mutation_generation, int) or not isinstance(verified_generation, int):
return False
if verified_generation < mutation_generation:
return False
evidence = state.get("evidence", [])
if not isinstance(evidence, list):
return False
accepted_kinds = completion_evidence_kinds(state)
latest = None
for record in reversed(evidence):
if not isinstance(record, dict) or record.get("generation") != mutation_generation:
continue
if record.get("result") == "fail" and record.get("kind") in FAILURE_RELEVANT_EVIDENCE_KINDS:
return False
if record.get("kind") in accepted_kinds:
latest = record
break
detail = latest.get("detail") if isinstance(latest, dict) else None
return (
isinstance(latest, dict)
and latest.get("workspaceId") == state.get("workspaceId")
and latest.get("result") == "pass"
and isinstance(detail, str)
and bool(detail.strip())
)
STATE_LOCK_TIMEOUT_SECONDS = 2.0
STATE_LOCK_STALE_SECONDS = 30.0
def _lock_path(fable_dir):
return os.path.join(fable_dir, "state.lock")
def _pid_alive(pid):
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except PermissionError:
return True
except OSError:
return False
def _stale_lock_can_be_removed(path):
try:
stat = os.stat(path)
if time.time() - stat.st_mtime < STATE_LOCK_STALE_SECONDS:
return False
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
if _pid_alive(data.get("pid")):
return False
except Exception:
pass
return True
except OSError:
return False
def _acquire_state_lock(fable_dir):
os.makedirs(fable_dir, exist_ok=True)
path = _lock_path(fable_dir)
deadline = time.monotonic() + STATE_LOCK_TIMEOUT_SECONDS
while True:
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump({"pid": os.getpid(), "createdAt": now_iso()}, handle)
handle.flush()
os.fsync(handle.fileno())
return path
except FileExistsError:
if _stale_lock_can_be_removed(path):
try:
os.remove(path)
continue
except OSError:
pass
if time.monotonic() >= deadline:
return None
time.sleep(0.01)
def _with_state_transaction(fable_dir, mutator):
lock = _acquire_state_lock(fable_dir)
if lock is None:
return None
try:
state = read_state(fable_dir)
if state is None:
return None
current_revision = int(state.get("stateRevision", 0))
updated = mutator(dict(state))
if not isinstance(updated, dict):
return None
updated["schemaVersion"] = STATE_SCHEMA_VERSION
updated["workspaceId"] = state["workspaceId"]
updated["stateRevision"] = current_revision + 1
if not write_state(fable_dir, updated):
return None
return updated
finally:
try:
os.remove(lock)
except OSError:
pass
def record_workspace_mutation(fable_dir):
"""Advance the mutation generation transactionally and stale prior verification."""
def mutate(state):
state["mutationGeneration"] = int(state.get("mutationGeneration", 0)) + 1
state["substantial"] = True
state["updatedAt"] = now_iso()
return state
return _with_state_transaction(fable_dir, mutate)
def record_command_result(fable_dir, failed):
"""Update durable failure state transactionally after a command result."""
def mutate(state):
if failed:
state["failureStreak"] = int(state.get("failureStreak", 0)) + 1
if state["failureStreak"] >= 2 and state.get("phase") != "complete":
state["phase"] = "recovering"
state["currentSkill"] = "fable-recover"
state["substantial"] = True
else:
state["failureStreak"] = 0
state["updatedAt"] = now_iso()
return state
return _with_state_transaction(fable_dir, mutate)
EVIDENCE_RE = re.compile(r"(evidence|verified|证据|凭证|验证)\s*[::]", re.IGNORECASE)
MIN_EVIDENCE_CHARS = 6
def closed_without_evidence(path):
"""List checked ledger cards with missing or hollow evidence markers."""
bad = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
for line in handle:
text = line.strip()
if text[:5].lower() != "- [x]":
continue
match = EVIDENCE_RE.search(text)
if not match or len(text[match.end():].strip()) < MIN_EVIDENCE_CHARS:
bad.append(text)
except Exception:
return []
return bad
def _sessions_dir():
directory = os.path.join(tempfile.gettempdir(), "fable-mode-sessions")
os.makedirs(directory, exist_ok=True)
return directory
def _safe_sid(session_id):
return re.sub(r"[^A-Za-z0-9._-]", "_", str(session_id))[:120]
def _streak_file(session_id):
return os.path.join(_sessions_dir(), _safe_sid(session_id) + ".fails")
def load_fail_streak(session_id):
if not session_id:
return 0
try:
with open(_streak_file(session_id), encoding="utf-8") as handle:
return max(0, int(handle.read().strip() or 0))
except Exception:
return 0
def save_fail_streak(session_id, count):
if not session_id:
return
try:
directory = _sessions_dir()
now = time.time()
for filename in os.listdir(directory):
candidate = os.path.join(directory, filename)
try:
if now - os.path.getmtime(candidate) > 7 * 86400:
os.remove(candidate)
except OSError:
pass
with open(_streak_file(session_id), "w", encoding="utf-8") as handle:
handle.write(str(int(count)))
except Exception:
pass
def parse_ledger(path):
"""Return (open_items, has_any, paused) for a ledger file."""
open_items = []
has_any = False
paused = False
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
for line in handle:
text = line.strip()
if text.upper().startswith("PAUSED"):
reason = text[len("PAUSED"):].strip(" \t::-–")
if len(reason.strip()) >= 3:
paused = True
continue
if len(text) < 4 or not text.startswith("- ["):
continue
mark = text[3:4].lower()
if text[2:5] == "[ ]":
has_any = True
open_items.append(text)
elif mark in ("x", "~"):
has_any = True
except FileNotFoundError:
return [], False, False
except Exception:
return [], False, False
return open_items, has_any, paused
SHA-256: 4ffbc9ff7953698454cc64045fc388e96ecf96803db7b7a5c5a0ca6b5d201f90