Skip to content
Open
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
17 changes: 11 additions & 6 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Before code, write `docs/SPEC.md`: requirements, approach, **task cards** (skele
Each card runs in a **fresh context**, fed only the relevant SPEC excerpt — no reasoning garbage from prior cards. Run acceptance the moment it's done; **don't advance until it passes**. Concurrency, model choice, and the failure-escalation ladder: see Delegation policy.

### 3. Adversarial self-check
Important output is never "generate and ship". Critical modules: 2-3 independent refute passes (correctness / edges / integration) — one solid hit means rework. Wide solution spaces: N approaches + judge + synthesize. **Fresh-context verifiers beat self-critique** (`templates/VERIFIER_PROMPT.md`); verifier prompts say "assume broken, falsify hard", never "take a look".
Important output is never "generate and ship". Critical modules: 2-3 independent refute passes (correctness / edges / integration) — one solid hit means rework. Wide solution spaces: N approaches + judge + synthesize. **Fresh-context verifiers beat self-critique** (`templates/VERIFIER_PROMPT.md`); verifier prompts say "assume broken, falsify hard", never "take a look". **Information isolation is what makes it adversarial**: the verifier gets ONLY the SPEC excerpt + the artifact — never the worker's transcript, notes, claimed evidence, or expected verdict; a verifier that reads the worker's story grades the story, not the work. Prefer 2-3 verifiers with *different* lenses over N identical ones, and never a verifier weaker than the implementer.

**Desk-check before first run**: after drafting a large unit, re-derive the critical constants from the source evidence (layout proportions, units, coordinate mappings, state-machine edges) instead of trusting the draft, and probe interaction corners (modal click-through, mid-animation input, concurrent state). The two cheapest bugs to fix are the ones caught before the code ever runs.

Expand Down Expand Up @@ -130,12 +130,14 @@ Selecting a profile: the user's words above, env `FABLE_ROUTING=quality|balanced

## Enforcement layer (hooks — mechanics in `hooks/README.md`)

Four hooks turn the most-shirked rules into hard blocks. Armed **per project** by a `.fable/` directory (searched upward, bounded at the git root); without it they pass through silently. Pressure applies **per round** via `.fable/LEDGER.md`:
Five hooks turn the most-shirked rules into hard blocks. Armed **per project** by a `.fable/` directory (searched upward, bounded at the git root); without it they pass through silently. Pressure applies **per round** via `.fable/LEDGER.md`:

```
- [ ] 1. card (machine-checkable acceptance) <- open: guards enforce
- [x] 2. done -- evidence: pytest 21/21 <- [x] REQUIRES a substantive evidence note
- [x] 2. done -- evidence: `pytest -q` 21/21 <- [x] REQUIRES substantive evidence; a cited `command` is checked against the machine-written evidence log
- [~] 3. not this round -- deferred: reason
MODE: light <- optional: light round (triage) — ceremony off, honesty rules stay
REPLAY: on <- optional: re-run cited acceptances before the round may end
PAUSED: reason <- a line anywhere: enforcement off
```

Expand All @@ -144,13 +146,16 @@ be attributable. Evidence notes must be substantive: `evidence: ok` counts as
missing.)

- **Spawn Guard** (PreToolUse Agent/Task/Workflow): blocks a detailed spawn while the ledger has no **open** cards — no ledger, and equally a ledger holding only a finished round's closed cards (design gate: new fan-out needs a live card) — and blocks any spawn requesting a **model stronger than the session's** (model ceiling — checked on the `model` param and `model:` literals in Workflow scripts; stays active even when paused, it protects quota, not workflow).
- **Fail-Streak Reminder** (PostToolUse Bash, advisory): every 3rd consecutive failing command injects the attribution ladder — stops grinding on the wrong layer mechanically, not by willpower.
- **Close Guard** (Stop): blocks ending the turn while open `- [ ]` items remain, **and** while any `- [x]` lacks an `-- evidence:` note (evidence-on-close: adjectives don't close cards).
- **Fail-Streak Guard** (PostToolUse Bash): every 3rd consecutive failing command injects the attribution ladder (advisory); at the **6th** it turns structural — every further failure is answered with a blocking demand to stop retrying, distill `-- tried: <what was ruled out>` into the card, and restart from a fresh context. Writing the note (or a success) resets it: the distillation is the exit, so the lesson survives the context that learned it.
- **Evidence Logger** (PostToolUse Bash, passive): appends every command's real outcome (command, exit code, output tail) to `.fable/evidence.jsonl` — the machine-written record the Close Guard checks citations against. Records even while paused; evidence gaps are worse than pauses.
- **Close Guard** (Stop): blocks ending the turn while open `- [ ]` items remain, while any `- [x]` lacks an `-- evidence:` note (evidence-on-close: adjectives don't close cards), while any cited evidence `command` has **no successful run in the evidence log** (machine corroboration: a citation that never ran is not evidence), and — with `REPLAY: on` — while any cited acceptance fails when **re-run now** ('passed once' is not 'still passes').
- **Profile Injector** (SessionStart): injects tier + routing + habits, **sized to the ledger state** — full when a round is starting/active, minimal when idle, one line when paused.

**Wrap-up lint**: `python3 <skill-dir>/hooks/fable_lint.py <project_dir>` — machine-checks the discipline itself (SPEC source tags present, open cards name acceptance, closed cards carry evidence). Run it at step 7 of the execution template; findings are open work.

**Per-task granularity**: *active* (open cards) = full enforcement; *idle* (no/all-closed cards) = close guard quiet and small tasks flow freely, but a **detailed** fan-out still needs a live card first; *paused* (a `PAUSED: reason` line) = guards off except the ceiling. Write PAUSED only when the user steers to work unrelated to the round; remove it to resume. Small spawns (<1500 chars) and forks skip the design gate; everything fails open (a guard bug never bricks the session); loop-safe.
**Per-task granularity**: *active* (open cards) = full enforcement; *idle* (no/all-closed cards) = close guard quiet and small tasks flow freely, but a **detailed** fan-out still needs a live card first; *light* (a `MODE: light` line) = triage for small rounds — design gate and open-cards-block-stop off, evidence honesty and the ceiling stay armed; *paused* (a `PAUSED: reason` line) = guards off except the ceiling. Write PAUSED only when the user steers to work unrelated to the round; remove it to resume. Small spawns (<1500 chars) and forks skip the design gate; everything fails open (a guard bug never bricks the session); loop-safe.

**Triage — pick the round's weight deliberately**: full ceremony (SPEC + cards + evidence + replay) earns its cost on long, multi-file, hard-to-reverse work; on a small, immediately-verifiable round it is overhead the model will learn to game. Start a small round with `MODE: light`; upgrade to full the moment scope grows past a couple of files or the work becomes hard to verify by eye. Choosing the weight is part of the discipline — applying maximum ceremony everywhere is not rigor, it's noise.

**For substantial work: after writing the SPEC, `mkdir .fable` + create `.fable/LEDGER.md` to get the mechanical backstop.** In the user's repo, suggest gitignoring `.fable/` (round state) while committing `docs/SPEC.md`/`PROGRESS.md` (durable docs).

Expand Down
23 changes: 16 additions & 7 deletions hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ The enforcement layer: turn a few of fable-mode's prose rules into Claude Code
hooks that actually block — ledger-before-delegation and close-verification,
built around this repo's SPEC.md/PROGRESS.md conventions.

## Four hooks + one lint CLI
## Five hooks + one lint CLI

| Hook | Event | What it does |
|---|---|---|
| `fable_profile_inject.py` | `SessionStart` | When the project has opted in, **auto-inject the tier by model + the six levers + ledger context recovery** (no need to type "use fable mode") |
| `fable_spawn_guard.py` | `PreToolUse` (Agent\|Task\|Workflow) | When opted in: **block a detailed spawn with no ledger** (forces the plan gate) and **block any spawn requesting a model stronger than the session's** (the model ceiling) |
| `fable_fail_streak.py` | `PostToolUse` (Bash) | Advisory, never blocks: at every 3rd **consecutive failing command**, inject the attribution ladder (harness → deployment → product; fix the class via an invariant). Streak state: `$TMPDIR/fable-mode-sessions/<sid>.fails`, reset on success. |
| `fable_close_guard.py` | `Stop` | While the ledger still has unchecked items, **block ending the turn** (cures early stopping / spinning). When all items are checked, **block if any `- [x]` lacks an evidence marker** (`-- evidence:` / `证据:`) — evidence-on-close. |
| `fable_fail_streak.py` | `PostToolUse` (Bash) | At every 3rd **consecutive failing command**, inject the attribution ladder (advisory). At the **6th**, turn structural: every further failure exits 2 with a demand to stop retrying, write `-- tried: <ruled out>` into the card, and restart fresh — the note (or a success) resets the streak. State: `$TMPDIR/fable-mode-sessions/<sid>.fails`. |
| `fable_evidence_log.py` | `PostToolUse` (Bash) | Passive recorder: appends every command's **real outcome** (command, exit code, output tail) to `.fable/evidence.jsonl` — the machine-written record citations are checked against. Records even while PAUSED. |
| `fable_close_guard.py` | `Stop` | While the ledger still has unchecked items, **block ending the turn** (cures early stopping / spinning). When all items are checked: **block if any `- [x]` lacks an evidence marker** (`-- evidence:` / `证据:`), **block if a cited evidence `command` has no successful run in the evidence log** (machine corroboration), and with `REPLAY: on` **block if a cited acceptance fails when re-run now**. |

`fable_lint.py` is **not a hook** — a one-shot CLI (`python3 fable_lint.py <project_dir>`)
for wrap-up or CI: SPEC exists and carries source tags ([measured]/[inferred]/[not-shown]
Expand Down Expand Up @@ -53,6 +54,8 @@ defaults to the conservative tier. This is SessionStart-only info (there is no
- [x] 2. done -- evidence: pytest 21/21
- [~] 3. not this round -- deferred: reason
PAUSED: reason <- optional line anywhere: suspend enforcement
MODE: light <- optional: light round — ceremony off, honesty stays
REPLAY: on <- optional: re-run cited acceptances at turn-end
ROUTING: frugal <- optional: model-routing profile for this round
TIER: throughput <- optional: concurrency tier for this round
```
Expand Down Expand Up @@ -91,7 +94,8 @@ state, so small tasks in a big project aren't taxed:
| starting (no cards yet) | design gate armed | full (~1.6KB) |
| **active** (open `- [ ]`) | full enforcement | full + context recovery |
| **idle** (all closed) | close guard quiet; detailed fan-out still needs a new open card | one-liner (~0.4KB) |
| **paused** (`PAUSED: reason` line) | off except model ceiling | one-liner (~0.2KB) |
| **light** (`MODE: light` line) | design gate + open-cards-block-stop off; evidence honesty, evidence log, fail-streak and ceiling stay | full |
| **paused** (`PAUSED: reason` line) | off except model ceiling + evidence log | one-liner (~0.2KB) |

## Model ceiling (mechanical)

Expand Down Expand Up @@ -141,7 +145,10 @@ use your actual absolute clone path if it differs:
"command": "python3 ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/fable-mode/hooks/fable_spawn_guard.py"}]}],
"PostToolUse": [{"matcher": "Bash",
"hooks": [{"type": "command",
"command": "python3 ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/fable-mode/hooks/fable_fail_streak.py"}]}],
"command": "python3 ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/fable-mode/hooks/fable_fail_streak.py"}]},
{"matcher": "Bash",
"hooks": [{"type": "command",
"command": "python3 ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/fable-mode/hooks/fable_evidence_log.py"}]}],
"Stop": [{"hooks": [{"type": "command",
"command": "python3 ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/fable-mode/hooks/fable_close_guard.py"}]}]
}
Expand All @@ -160,6 +167,8 @@ To disable entirely, remove the hooks block from settings.json.
No third-party deps, just run:

```bash
python3 tests/test_guards.py # 13 cases: opt-in detection, ledger presence, small-spawn/fork exemptions, git-root boundary, loop-safety, fail-open
python3 tests/test_inject.py # 9 cases: per-model tier, env override, ledger context recovery, JSON envelope, fail-open
python3 tests/test_guards.py # opt-in detection, ledger presence, exemptions, git-root boundary, loop-safety, fail-open
python3 tests/test_inject.py # per-model tier, env override, ledger context recovery, JSON envelope, fail-open
python3 tests/test_evidence.py # evidence log recording, citation corroboration, REPLAY re-runs
python3 tests/test_discipline.py # MODE: light triage, structural fail-streak
```
157 changes: 157 additions & 0 deletions hooks/_fable_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,128 @@ def load_session_model(session_id):
EVIDENCE_RE = re.compile(r"(evidence|verified|证据|凭证|验证)\s*[::]", re.IGNORECASE)


# --- machine-written evidence log (.fable/evidence.jsonl) ---
#
# The Evidence Logger hook appends one JSON line per Bash command:
# {"ts": <epoch>, "cmd": <command>, "exit": <int>, "tail": <output tail>}
# The Close Guard checks cited `commands` on `- [x]` cards against this log,
# so "the acceptance actually ran" is machine truth, not a self-reported note.

EVIDENCE_LOG = "evidence.jsonl"
EVIDENCE_LOG_MAX_BYTES = 512 * 1024 # rotate: keep the newest half beyond this
EVIDENCE_TAIL_CHARS = 200

_BACKTICK_RE = re.compile(r"`([^`]+)`")


def evidence_log_path(fable_dir):
return os.path.join(fable_dir, EVIDENCE_LOG)


def response_exit_code(tool_response):
"""Best-effort exit code from a Bash tool_response; None when unknown."""
r = tool_response
if isinstance(r, str):
m = re.search(r"[Ee]xit code[: ]+([0-9]+)", r)
return int(m.group(1)) if m else None
if not isinstance(r, dict):
return None
for key in ("exitCode", "exit_code", "code", "returncode"):
v = r.get(key)
if isinstance(v, int):
return v
for key in ("is_error", "isError"):
if r.get(key) is True:
return 1
text = " ".join(str(r.get(k, "")) for k in ("stdout", "stderr", "output"))
m = re.search(r"[Ee]xit code[: ]+([0-9]+)", text)
return int(m.group(1)) if m else None


def append_evidence(fable_dir, cmd, exit_code, tail):
"""Append one run record; rotate the log when it grows too large.
Best-effort, fail-open — recording must never disturb the session."""
try:
path = evidence_log_path(fable_dir)
try:
if os.path.getsize(path) > EVIDENCE_LOG_MAX_BYTES:
with open(path, encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
with open(path, "w", encoding="utf-8") as fh:
fh.writelines(lines[len(lines) // 2:])
except OSError:
pass
rec = {"ts": time.time(), "cmd": str(cmd)[:2000],
"exit": exit_code,
"tail": str(tail or "")[-EVIDENCE_TAIL_CHARS:]}
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass


def _norm_cmd(s):
return re.sub(r"\s+", " ", str(s)).strip()


def cited_commands(card_line):
"""Backtick-quoted commands in the *evidence part* of a `- [x]` line.
Returns [] when the evidence note cites no command (prose-only note)."""
m = EVIDENCE_RE.search(card_line)
if not m:
return []
return [_norm_cmd(c) for c in _BACKTICK_RE.findall(card_line[m.end():])
if _norm_cmd(c)]


def evidence_log_has_run(log_path, cited, want_success=True):
"""True if the log records a run whose command matches `cited`
(normalized substring, either direction) — successful when want_success."""
try:
with open(log_path, encoding="utf-8", errors="replace") as fh:
for line in fh:
try:
rec = json.loads(line)
except ValueError:
continue
cmd = _norm_cmd(rec.get("cmd", ""))
if not cmd:
continue
if cited in cmd or cmd in cited:
if not want_success or rec.get("exit") == 0:
return True
except Exception:
return False
return False


def uncorroborated_citations(ledger_p, log_path):
"""`- [x]` cards whose cited evidence command never ran successfully.

Machine check for "the acceptance actually ran": a card that cites a
`command` as evidence must have a successful run of that command in the
evidence log. Cards with prose-only evidence are not checked here (the
substantive-string rule still applies to them). Returns [] when the log
doesn't exist yet (projects predating the logger) — fail-open.
"""
if not os.path.isfile(log_path):
return []
bad = []
try:
with open(ledger_p, encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s[:5].lower() != "- [x]":
continue
cites = cited_commands(s)
if cites and not any(
evidence_log_has_run(log_path, c) for c in cites):
bad.append(s)
except Exception:
return []
return bad


# --- model-routing profiles (quality / balanced / frugal) ---

ROUTING_PROFILES = ("quality", "balanced", "frugal")
Expand All @@ -151,6 +273,41 @@ def read_tier(path):
return None


_REPLAY_RE = re.compile(r"^REPLAY\s*[::]\s*(on|off)\b", re.IGNORECASE)
_MODE_RE = re.compile(r"^MODE\s*[::]\s*(light|full)\b", re.IGNORECASE)


def read_mode(path):
"""Per-round ceremony weight from a `MODE: light|full` ledger line.

'light' = triage for small rounds: the design gate and open-cards-block-
stop are off, but evidence honesty (and the model ceiling) stay armed.
Default 'full'. Fail-open to 'full' on any read problem.
"""
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
m = _MODE_RE.match(line.strip())
if m:
return m.group(1).lower()
except Exception:
return "full"
return "full"


def read_replay(path):
"""True when the ledger opts into acceptance replay (`REPLAY: on`)."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
m = _REPLAY_RE.match(line.strip())
if m:
return m.group(1).lower() == "on"
except Exception:
return False
return False


def read_routing(path):
"""Per-round routing profile from a `ROUTING: <profile>` ledger line.

Expand Down
Loading