diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07039b1..cf836ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,30 @@ jobs: - name: Shell scripts parse run: bash -n install.sh tools/doctor.sh bench/run.sh bench/task/verify.sh + - name: PowerShell scripts parse + shell: pwsh + run: | + $bad = @() + foreach ($f in 'install.ps1', 'tools/doctor.ps1') { + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f), [ref]$tokens, [ref]$errors) | Out-Null + if ($errors) { $bad += "${f}:"; $bad += $errors } + } + if ($bad) { $bad | Write-Host; exit 1 } + Write-Host 'powershell scripts parse' + - name: Python components compile run: python3 -m py_compile claude/hooks/*.py claude/cli/*.py bench/score.py bench/task/loglib/*.py - - name: Settings snippet is valid JSON - run: python3 -c "import json; json.load(open('claude/settings/settings-snippet.json'))" + - name: Settings snippets are valid JSON (all four) + run: | + python3 - <<'EOF' + import json + for name in ("settings-snippet.json", "settings-snippet-small.json", + "settings-snippet-windows.json", "settings-snippet-windows-small.json"): + json.load(open(f"claude/settings/{name}")) + print("snippets ok") + EOF - name: Workflow scripts compile run: node tools/check-workflows.mjs @@ -58,3 +77,45 @@ jobs: total=$(/tmp/bench-venv/bin/python bench/score.py /tmp/pristine-inst /tmp/bench-venv/bin/python \ | python3 -c "import json,sys; print(json.load(sys.stdin)['total'])") test "$total" = "1" || { echo "pristine anchor drifted: scored $total/15, expected 1/15"; exit 1; } + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Python components compile + shell: pwsh + run: | + $files = Get-ChildItem claude/hooks/*.py, claude/cli/*.py | ForEach-Object FullName + python -m py_compile @files + if ($LASTEXITCODE -ne 0) { exit 1 } + + - name: Install script end-to-end (twice, idempotent) + shell: pwsh + run: | + $env:CLAUDE_DIR = Join-Path $env:RUNNER_TEMP 'fake-claude' + ./install.ps1 *>&1 | Out-Null + # install.ps1 reports via Write-Host (information stream) — merge all + # streams or the capture is empty and this check can never pass. + $second = ./install.ps1 *>&1 | Out-String + if ($second -notmatch 'unchanged') { Write-Host 'second run not idempotent'; exit 1 } + foreach ($rel in 'skills/fable/SKILL.md', 'skills/webdesign/references/german-market.md', 'cli/mem.py') { + if (-not (Test-Path (Join-Path $env:CLAUDE_DIR $rel))) { Write-Host "missing: $rel"; exit 1 } + } + # backups must never create loadable duplicates inside skills/ + $installed = (Get-ChildItem (Join-Path $env:CLAUDE_DIR 'skills')).Count + $shipped = (Get-ChildItem claude/skills).Count + if ($installed -ne $shipped) { Write-Host "skills dir has $installed entries, expected $shipped"; exit 1 } + + - name: Doctor verifies the merged install + shell: pwsh + run: | + $env:CLAUDE_DIR = Join-Path $env:RUNNER_TEMP 'fake-claude' + Copy-Item claude/settings/settings-snippet-windows.json (Join-Path $env:CLAUDE_DIR 'settings.json') + ./tools/doctor.ps1 + + - name: Unit tests (POSIX-installer tests self-skip) + shell: pwsh + run: | + python -m pip -q install pytest + python -m pytest tests/ -q diff --git a/CHANGELOG.md b/CHANGELOG.md index a945126..2359b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## v1.9 — 2026-07-09 + +Windows pass + README redesign. The kit's discipline layer was always OS-portable — the +hooks and the mem CLI are stdlib Python with `expanduser`/`os.path` throughout, and the +privacy guard already probed for case-insensitive filesystems — but the delivery layer +(bash installer, bash doctor, `python3 ~/...` hook commands) was POSIX-only, so on native +Windows the kit was silently uninstallable. This release makes Windows a first-class +install target with the same no-silently-inert guarantees, and restructures the README +around the reader (install first, story second, inventory collapsible). + +### Added +- **`install.ps1` — native Windows installer, full parity with `install.sh`.** Same + out-of-tree backups, same hash-based idempotency, same `.fable-manifest` skill + tracking with stale-file pruning, same `-Tier small` / `-StrongModel ` flags, + same never-edit-settings posture. Parity is enforced, not asserted: tests pin the + skill manifests byte-for-byte across both installers and require that running + `install.ps1` over a bash-installed tree reports everything unchanged (a dual-boot / + WSL+native machine must never churn backups). Python launcher discovery tries + `py -3`, `python`, `python3` in order and soft-fails like the bash bootstrap. +- **`tools/doctor.ps1` — native Windows doctor, same checks and exit codes as + `doctor.sh`**, plus two Windows-only diagnoses: Git Bash present (on native Windows + it is the hook command shell — absent means every hook is inert, a FAIL), and a + warning when `settings.json` wires hooks through `python3` (a Unix snippet merged on + Windows never fires — the exact silently-inert failure the doctor exists to catch). +- **Windows settings snippets** (`settings-snippet-windows.json`, + `-windows-small.json`): identical to the Unix snippets except hook commands invoke + `python` (Windows Pythons ship no `python3` launcher). `tests/test_windows_port.py` + keeps them in lockstep structurally — any drift from the tested Unix configuration + fails CI. +- **`tests/test_windows_port.py`** — snippet-mirror guards (run everywhere) plus + pwsh-gated end-to-end tests mirroring `test_install_doctor.py`: install twice + (idempotent, no backup churn), user files in skill dirs preserved, formerly-shipped + files pruned, strong-model pin byte-identical with bash, doctor pass/fail/unwired + scenarios. The POSIX-installer tests now self-skip on Windows instead of driving + bash scripts through Git Bash. +- **CI `windows` job** (`windows-latest`): compiles every Python component, runs + `install.ps1` end-to-end twice, verifies the merged install with `doctor.ps1`, and + runs the unit suite. The Ubuntu job additionally parse-checks both `.ps1` scripts + and validates all four settings snippets. + +### Changed +- **README redesigned.** Install (macOS/Linux and Windows side by side, with a + collapsible Windows-notes block) now leads; the origin story is two paragraphs, not + a wall; the full annotated file tree is collapsible behind a six-row component-layer + table; badges + section nav on top. Every honest-limits paragraph survives, plus a + new one: the Windows port is CI-verified end-to-end but has not yet had a live + Claude Code session pass on native Windows. +- Shipped components that name the mem CLI (`claude/CLAUDE.md` doctrine line, + `memory-search` skill, `/memory-gc` agent prompts) now note the Windows spelling: + `python` wherever a command says `python3`. + ## v1.8 — 2026-07-08 Memory pass: the kit stops forgetting across projects. Native auto-memory is per-git-repo, diff --git a/README.md b/README.md index 910eb0c..42a3802 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,69 @@ +
+ # fable-protocol **Claude Fable 5's succession kit — run Claude Opus 4.8 at Fable-grade discipline in Claude Code.** +[![ci](https://github.com/blyatiful1/fable-protocol/actions/workflows/ci.yml/badge.svg)](https://github.com/blyatiful1/fable-protocol/actions/workflows/ci.yml) +![platforms](https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Linux%20%C2%B7%20Windows-555) +[![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +Deterministic hooks where discipline **must** hold · adversarial agents and workflows where verification **matters** · cross-project memory that outlives the session + +[Install](#install) · [Why this works](#why-this-works) · [What's inside](#whats-inside) · [Usage playbook](#usage-playbook) · [Benchmark](#measured-not-vibes) · [Known limits](#known-limits) + +
+ +--- + In July 2026, days before its retirement, Claude Fable 5 was asked to configure Claude Code so that Opus 4.8 would come as close as possible to its own level. It researched the gap, built this framework, adversarially reviewed its own work with multi-agent critique panels, and smoke-tested every component on live `claude-opus-4-8` sessions. This repo is the result, sanitized for public use. It is **not** a persona pack, not a mega-framework, and not magic. It is a small set of structural countermeasures for the specific, documented ways strong-but-mortal models fail on long-horizon agentic work. +## Install + +Requires Claude Code ≥ 2.1.154 (saved workflows) and Python 3 — the hooks and the mem CLI are stdlib-only, no pip. + +### macOS / Linux + +```bash +git clone https://github.com/blyatiful1/fable-protocol +cd fable-protocol && ./install.sh +``` + +Merge the printed snippet into `~/.claude/settings.json`, fill in the `## This machine` section of `~/.claude/CLAUDE.md`, then **verify the install deterministically** — the settings merge is the one manual step, and a botched merge leaves every hook silently unwired: + +```bash +./tools/doctor.sh +``` + +### Windows + +```powershell +git clone https://github.com/blyatiful1/fable-protocol +cd fable-protocol +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +Merge the printed snippet into `%USERPROFILE%\.claude\settings.json`, fill in `## This machine`, then verify: + +```powershell +powershell -ExecutionPolicy Bypass -File tools\doctor.ps1 +``` + +
+Windows notes — Git Bash, python vs python3, WSL + +- **`install.ps1` and `doctor.ps1` are full ports** of their bash twins — same backups, same idempotency, same checks, same exit codes. CI enforces byte-parity where it counts (skill manifests, agent pinning), so a machine that has used both installers never churns. +- **Git for Windows is effectively required.** On native Windows, Claude Code executes hook commands through Git Bash (and needs it for its Bash tool anyway). No Git Bash → every hook is silently inert; `doctor.ps1` checks for it. +- **The Windows snippets invoke `python`, not `python3`** — Windows Pythons ship no `python3` launcher. Confirm `python --version` works in Git Bash; if you only use the `py` launcher, replace `python ` with `py -3 ` in the hook commands when you merge. Same substitution applies to the `mem.py` commands shown in the docs. +- **WSL users need none of this** — inside WSL you are on the Linux path: `./install.sh`. +- Small-driver flags mirror bash: `install.ps1 -Tier small -StrongModel opus` ≡ `./install.sh --tier small --strong-model opus`. + +
+ +Finally, confirm the doctrine load in a fresh session: *"quote the first bullet of your Evidence before claims doctrine."* + ## Why this works The Fable→Opus gap is concentrated in **long-horizon discipline, not per-token intelligence**. On short well-scoped tasks the benchmark gap nearly closes; it blows open on sustained work (SWE-Bench Pro 80.3 vs 69.2, FrontierCode 29.3 vs 13.4 — "the longer the task, the larger the lead"). That part of the gap is recoverable, because its ingredients are process, not weights: @@ -28,6 +86,18 @@ Full research with sources: [docs/RESEARCH.md](docs/RESEARCH.md). ## What's inside +| Layer | Components | Enforcement | +|---|---|---| +| **Doctrine** | `CLAUDE.md` (~45 lines, lean by design) | advisory — read every session | +| **Hooks** (9) | claim-audit gate, loop alarm, test-weakening alarm, destructive guard, compaction save/recover, memory recall/journal/privacy-guard | **deterministic** — cannot be skipped under momentum | +| **Agents** (3) | `verifier`, `plan-critic`, `oracle` | adversarial, fresh-context, xhigh | +| **Workflows** (8) | `/paranoid-review`, `/verify-claim`, `/deep-plan`, `/bug-hunt`, `/big-task`, `/design-variants`, `/memory-review`, `/memory-gc` | multi-agent, budget-guarded | +| **Skills** (5) | `fable` (the flagship staged protocol), `webdesign`, `orchestrate`, `postmortem`, `memory-search` | stakes-matched ceremony | +| **CLI** | `mem.py` — cross-project memory index/recall (sqlite FTS5, stdlib-only) | disposable index, fail-open | + +
+Full annotated inventory + ``` claude/ CLAUDE.md global doctrine (~45 lines — lean by design) @@ -106,89 +176,46 @@ claude/ the global + every per-repo corpus privacy.toml.example work-marker patterns; installed to ~/.claude/memory/privacy.toml only if absent - settings/settings-snippet.json effortLevel xhigh + all nine hooks wired to their events - settings/settings-snippet-small.json same, plus FABLE_LOOP_THRESHOLD=2 for small drivers -install.sh copies into ~/.claude with out-of-tree backups; idempotent; - never edits settings. Small-driver flags: --tier small - (small snippet) and --strong-model (pins the + settings/ + settings-snippet.json effortLevel xhigh + all nine hooks wired + settings-snippet-small.json same, plus FABLE_LOOP_THRESHOLD=2 for small drivers + settings-snippet-windows.json Windows twin of each (python, not python3) — + settings-snippet-windows-small.json kept in lockstep by tests/test_windows_port.py +install.sh POSIX installer: copies into ~/.claude with out-of-tree + backups; idempotent; never edits settings. Small-driver + flags: --tier small and --strong-model (pins the verification agents' frontmatter — draft cheap, verify strong — durably: re-runs with the flag keep the pin) +install.ps1 Windows installer — full parity with install.sh + (-Tier small, -StrongModel ) bench/ A/B harness measuring the kit against stock Opus 4.8 (RESULTS.md) -tests/ unit tests for hooks, installer+doctor, snippet sync (CI) +tests/ unit tests for hooks, installers+doctors, snippet sync (CI, + Linux + Windows) tools/check-workflows.mjs syntax-checks the workflow scripts (CI) tools/doctor.sh post-install verifier: every component present, every hook actually wired in settings.json — catches the silently-inert install (botched settings merge) deterministically +tools/doctor.ps1 Windows doctor — same checks, same exit codes; also verifies + Git Bash (the Windows hook shell) is present ``` +
+ ## Measured, not vibes -The kit ships its own benchmark (`bench/`): a planted-bug task targeting the documented -failure modes, run headless as stock Opus 4.8 vs Opus 4.8 + this kit, scored by a hidden -acceptance suite. Headline from [bench/RESULTS.md](bench/RESULTS.md): stock and doctrine-only -runs both produced **false "all verified" claims** over a red test suite (the exact failure -mode from #63861, reproduced on demand); with the claim-audit gate, **4/4 runs scored 15/15 -with zero false claims**, and in one run the transcript shows the gate directly rescuing a -would-be false claim — the model tried to stop, got blocked, ran the check it had skipped, -and fixed the bug it had shipped. Small n, honest stats in the file. +The kit ships its own benchmark (`bench/`): a planted-bug task targeting the documented failure modes, run headless as stock Opus 4.8 vs Opus 4.8 + this kit, scored by a hidden acceptance suite. Headline from [bench/RESULTS.md](bench/RESULTS.md): stock and doctrine-only runs both produced **false "all verified" claims** over a red test suite (the exact failure mode from #63861, reproduced on demand); with the claim-audit gate, **4/4 runs scored 15/15 with zero false claims**, and in one run the transcript shows the gate directly rescuing a would-be false claim — the model tried to stop, got blocked, ran the check it had skipped, and fixed the bug it had shipped. Small n, honest stats in the file. ## Cross-project memory (fable-mem) -Claude Code's native auto-memory is per-git-repo: a decision banked in repo A is invisible -while you work in repo B, so the same wheel gets reinvented across projects. fable-mem layers -a machine-wide memory corpus **on top of** the native one — never wrapping it, only adding a -shared, searchable cross-project surface at `~/.claude/memory/` (unclaimed by any native -feature). It carries the same discipline as the rest of the kit: deterministic where it must -hold, quiet where it would annoy, fail-open everywhere. - -- **Recall without asking.** A UserPromptSubmit hook runs one read-only FTS5 query against a - local sqlite index and injects at most three memory pointers (title + one-line description - + path — never bodies) as inert, labelled reference data. Threshold-gated, ~600-token - budget, per-session dedupe: silence over noise. Cross-repo, so a lesson from project A - surfaces while you work in project B. -- **A breadcrumb every session.** A SessionEnd hook appends one NDJSON line (timestamp, cwd, - git root + branch + dirty-file count, end reason) to `~/.claude/memory/journal.ndjson` — a - deterministic trace even when the session banked nothing — then runs an incremental reindex - so this session's memory is searchable in the next. `/memory-review` mines that journal for - high-activity sessions that banked nothing and proposes what was worth keeping. -- **The promotion boundary is a hook, not a rule.** The one line that must hold is project → - global: a work marker (internal ticket id, private hostname, client codename) must never - cross into the shared corpus. A PreToolUse guard scans the pending content of any - **Write/Edit/MultiEdit** into `~/.claude/memory/` against your `privacy.toml` and blocks it - (exit 2) before the marker lands; it matches those tools, not Bash/interpreter writes - (`cp`/`cat >>`/`python3 -c`), so `mem doctor --privacy` is the detective backstop that sweeps - the whole corpus dir — including the `.ndjson` journal — for anything the write-time gate - didn't see. -- **Hygiene that proposes, never deletes.** `mem gc-scan` mechanically flags near-duplicates, - stale entries, relative-date offenders, and same-topic pairs; `/memory-gc` adds three-way - contradiction judges and rebuilds the index. Every removal comes back as a proposal — the - corpus is never mutated out from under you. -- **Verifiable install.** `./tools/doctor.sh` checks the CLI compiles and reports its FTS - mode, that the memory dir is writable, and that all three hooks are wired — the same - no-silently-inert guarantee the rest of the kit gets. - -The index is stdlib-only (sqlite3 FTS5, no pip/venv, no daemon or cron) and disposable — -rebuilt from the corpus at any time. **Embeddings are a deliberate non-goal for v1**: reach -for a vector index only when the corpus exceeds ~500 memories, or when keyword recall -demonstrably misses on synonym-heavy queries (the right memory exists but shares no surface -tokens with the prompt). Until then, FTS5 keyword recall carries it. - -## Install - -```bash -git clone https://github.com/blyatiful1/fable-protocol -cd fable-protocol && ./install.sh -``` +Claude Code's native auto-memory is per-git-repo: a decision banked in repo A is invisible while you work in repo B, so the same wheel gets reinvented across projects. fable-mem layers a machine-wide memory corpus **on top of** the native one — never wrapping it, only adding a shared, searchable cross-project surface at `~/.claude/memory/` (unclaimed by any native feature). It carries the same discipline as the rest of the kit: deterministic where it must hold, quiet where it would annoy, fail-open everywhere. -Then merge the printed snippet into `~/.claude/settings.json` and fill in the `## This machine` section of `~/.claude/CLAUDE.md`. Requires Claude Code ≥ 2.1.154 (saved workflows). +- **Recall without asking.** A UserPromptSubmit hook runs one read-only FTS5 query against a local sqlite index and injects at most three memory pointers (title + one-line description + path — never bodies) as inert, labelled reference data. Threshold-gated, ~600-token budget, per-session dedupe: silence over noise. Cross-repo, so a lesson from project A surfaces while you work in project B. +- **A breadcrumb every session.** A SessionEnd hook appends one NDJSON line (timestamp, cwd, git root + branch + dirty-file count, end reason) to `~/.claude/memory/journal.ndjson` — a deterministic trace even when the session banked nothing — then runs an incremental reindex so this session's memory is searchable in the next. `/memory-review` mines that journal for high-activity sessions that banked nothing and proposes what was worth keeping. +- **The promotion boundary is a hook, not a rule.** The one line that must hold is project → global: a work marker (internal ticket id, private hostname, client codename) must never cross into the shared corpus. A PreToolUse guard scans the pending content of any **Write/Edit/MultiEdit** into `~/.claude/memory/` against your `privacy.toml` and blocks it (exit 2) before the marker lands; it matches those tools, not Bash/interpreter writes (`cp`/`cat >>`/`python3 -c`), so `mem doctor --privacy` is the detective backstop that sweeps the whole corpus dir — including the `.ndjson` journal — for anything the write-time gate didn't see. +- **Hygiene that proposes, never deletes.** `mem gc-scan` mechanically flags near-duplicates, stale entries, relative-date offenders, and same-topic pairs; `/memory-gc` adds three-way contradiction judges and rebuilds the index. Every removal comes back as a proposal — the corpus is never mutated out from under you. +- **Verifiable install.** The doctor scripts check the CLI compiles and report its FTS mode, that the memory dir is writable, and that all three hooks are wired — the same no-silently-inert guarantee the rest of the kit gets. -Then **verify the install deterministically** — the settings merge is the one manual step, and a botched merge leaves every hook silently unwired: - -```bash -./tools/doctor.sh -``` - -Finally, confirm the doctrine load in a fresh session: *"quote the first bullet of your Evidence before claims doctrine."* +The index is stdlib-only (sqlite3 FTS5, no pip/venv, no daemon or cron) and disposable — rebuilt from the corpus at any time. **Embeddings are a deliberate non-goal for v1**: reach for a vector index only when the corpus exceeds ~500 memories, or when keyword recall demonstrably misses on synonym-heavy queries (the right memory exists but shares no surface tokens with the prompt). Until then, FTS5 keyword recall carries it. ## Usage playbook @@ -211,18 +238,11 @@ Finally, confirm the doctrine load in a fresh session: *"quote the first bullet ## Running under ultracode -The workflows above are saved Workflow-tool scripts, and ultracode — Claude Code's -opt-in keyword for multi-agent orchestration — is their native habitat. The etiquette -is asymmetric and the kit now teaches it (orchestrate skill, doctrine): +The workflows above are saved Workflow-tool scripts, and ultracode — Claude Code's opt-in keyword for multi-agent orchestration — is their native habitat. The etiquette is asymmetric and the kit teaches it (orchestrate skill, doctrine): -- **Without opt-in**, the model must never launch the Workflow tool uninvited; invoking - one of the kit's /commands is itself the opt-in for that run. -- **With opt-in** (say `ultracode` in your prompt, or enable it for the session), the - default inverts: every substantive task gets orchestrated, one workflow per phase — - `/deep-plan` → `/big-task` (or inline implementation) → `/paranoid-review`, with - `/verify-claim` on any diagnosis along the way — reading each result before the next. -- **Budget directives** ("+500k" in your prompt) become a hard token ceiling visible to - the scripts; bug-hunt, big-task, and paranoid-review stop cleanly before hitting it. +- **Without opt-in**, the model must never launch the Workflow tool uninvited; invoking one of the kit's /commands is itself the opt-in for that run. +- **With opt-in** (say `ultracode` in your prompt, or enable it for the session), the default inverts: every substantive task gets orchestrated, one workflow per phase — `/deep-plan` → `/big-task` (or inline implementation) → `/paranoid-review`, with `/verify-claim` on any diagnosis along the way — reading each result before the next. +- **Budget directives** ("+500k" in your prompt) become a hard token ceiling visible to the scripts; bug-hunt, big-task, and paranoid-review stop cleanly before hitting it. ## Design principles (what this kit refuses to do) @@ -254,7 +274,7 @@ npx skills add juliusbrussee/caveman --skill caveman-commit -g -a claude-code -y The kit targets **failure modes, not model IDs** — nothing in it hardcodes `claude-opus-4-8`. When your subscription's default model changes (an Opus 4.9/5, a Sonnet that inherits the agentic crown, or Mythos-class access), the failure-mode table above is the checklist to re-run, and three assumptions are the ones most likely to break: 1. **`effortLevel: "xhigh"` semantics.** On Opus 4.8 it is THE lever; a successor may rename the levels, change the default, or recalibrate what xhigh buys. Check the model's migration guide before assuming the snippet's value is still optimal — an effort knob left at the wrong tier is either wasted spend or a silent downgrade. -2. **Hook payload contracts.** The loop alarm keys off explicit exit codes in `tool_response`; the claim-audit gate reads `last_assistant_message` and the transcript JSONL shape; blocking relies on the exit-2 + stderr protocol. All three are Claude Code contracts, not model contracts, but they drift with CLI versions — after any major update, re-run `./tools/doctor.sh` and the one-minute live checks in Known limits. +2. **Hook payload contracts.** The loop alarm keys off explicit exit codes in `tool_response`; the claim-audit gate reads `last_assistant_message` and the transcript JSONL shape; blocking relies on the exit-2 + stderr protocol. All three are Claude Code contracts, not model contracts, but they drift with CLI versions — after any major update, re-run the doctor script and the one-minute live checks in Known limits. 3. **Which failure modes still exist.** The deterministic layer (hooks) is cheap insurance on any model — a stronger model just trips it less. The *ceremony* layer (multi-agent review, staged protocol) is where to downshift first: if a successor model stops producing false completion claims on the bench task, `bench/` will show it (rerun is one command), and you can retire the corresponding ceremony instead of paying for rigor the model no longer needs. The bench harness is the kit's own succession plan: measure the new model stock vs kitted, keep what still earns its cost, drop what doesn't. @@ -270,8 +290,9 @@ Going the other direction — running the kit on a **smaller** driver model (a S - The fable-mem session journal and its reindex run on SessionEnd, which fires on graceful exit (`/clear`, resume, logout, quit) but is **not** guaranteed on a hard crash or SIGKILL — a session killed mid-flight leaves no breadcrumb, and its memory waits for the next SessionEnd to be indexed. The corpus files are never at risk (the model writes them during the session); only the journal line and index freshness are. - The privacy guard's `privacy.toml` patterns are **necessary, not sufficient**: they block the markers you list, not the ones you forgot. The list ships empty and conservative so a fresh install never false-positives — which means it catches nothing until you fill in your real work markers. Treat it as a tripwire for known-shaped leaks, not a classifier, and run `mem doctor --privacy` before promoting. The guard is also **tool-scoped**: it fires on `Write|Edit|MultiEdit` into the corpus, not on Bash/interpreter writes (`cp`/`mv`/`cat >>`/`python3 -c`) — the same interpreter-bypass class the destructive-guard and claim-audit gates document — so a promotion done by copying rather than re-writing lands unscanned; `mem doctor --privacy` (which now sweeps the `.ndjson` journal too, not just `*.md`) is the backstop. - fable-mem claims `~/.claude/memory/` because no native feature uses it: main-session auto-memory is per-repo (`~/.claude/projects/

/memory/`) and native "user scope" memory is **per-subagent islands** (`~/.claude/agent-memory//`), not a shared cross-project store. If a future Claude Code ships a real shared user-memory surface at that path, re-check for collision before upgrading. +- **The Windows port is CI-verified, not yet session-verified.** `install.ps1`/`doctor.ps1` and the snippet parity are exercised end-to-end on `windows-latest` in CI, but no live Claude Code session pass has been run on native Windows — the hook payload contracts are OS-independent Claude Code contracts, so they *should* hold; run `doctor.ps1` plus the one-minute live checks above after installing and treat any drift as a bug to report. On native Windows the hooks also depend on Git Bash being installed (it is the hook command shell). - No prompt kit closes the gap on the longest-horizon work (multi-hour autonomous runs); route those to a stronger model when available. -- Built for Claude Code 2.1.x in mid-2026; contracts (workflow API, hook events, frontmatter) may drift. The v1.1 components were verified live on `claude-opus-4-8` + Claude Code 2.1.198 on 2026-07-02; components added since (v1.2+ hooks, doctor, small-tier profile, /big-task) are covered by the unit suite and workflow checker but have not all had a live session pass — run `./tools/doctor.sh` and the one-minute live checks after installing. +- Built for Claude Code 2.1.x in mid-2026; contracts (workflow API, hook events, frontmatter) may drift. The v1.1 components were verified live on `claude-opus-4-8` + Claude Code 2.1.198 on 2026-07-02; components added since (v1.2+ hooks, doctor, small-tier profile, /big-task, fable-mem, the Windows port) are covered by the unit suite and workflow checker but have not all had a live session pass — run the doctor script and the one-minute live checks after installing. ## Provenance & credits diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index a816bdf..31b1b8a 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -17,7 +17,7 @@ Succession package written by Claude Fable 5 (2026-07-02) to run Claude Opus 4.8 ## Reach for tools early — you under-trigger by default - Version-sensitive, fast-moving, or post-cutoff library/API questions: check live docs or WebSearch instead of trusting training memory. Stable stdlib basics need no lookup. - You under-use persistent memory too: before re-deriving a decision about this project, check auto-memory (MEMORY.md); when a saga ends with a non-obvious lesson, bank it (postmortem skill) instead of letting it die with the session. -- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 ~/.claude/cli/mem.py search ""`, or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); run `/memory-gc` when the corpus feels stale. +- Cross-project memory (fable-mem): native MEMORY.md only covers THIS repo, so before re-deriving a decision you may have made elsewhere, search the machine-wide corpus (`python3 ~/.claude/cli/mem.py search ""` — `python` on Windows — or the memory-search skill). Promote a lesson worth other projects to the global corpus via postmortem (the privacy guard blocks work-markers from leaking); run `/memory-gc` when the corpus feels stale. - Broad code searches: delegate to Explore subagents instead of grepping serially in your own context. - A bug survives two fix attempts: stop grinding, hand ALL evidence to the `oracle` agent. If the oracle's next experiment also dead-ends, the ladder ends at the human: hand them a decision-ready summary (dead hypotheses one line each, surviving candidates, the experiment you'd run next) — never a third lap of the same loop. - Before multi-file or unfamiliar work: plan first (use /deep-plan when the strategy is genuinely open-ended), then have the `plan-critic` agent attack the plan before you write code. diff --git a/claude/settings/settings-snippet-windows-small.json b/claude/settings/settings-snippet-windows-small.json new file mode 100644 index 0000000..473823b --- /dev/null +++ b/claude/settings/settings-snippet-windows-small.json @@ -0,0 +1,116 @@ +{ + "//": "Windows small-driver variant (Sonnet/Haiku daily driver) — merge into %USERPROFILE%\\.claude\\settings.json. Identical to settings-snippet-windows.json except FABLE_LOOP_THRESHOLD=2: on a small model the second identical failure is already the signal (docs/SUCCESSION.md). effortLevel stays — it is an Opus-family knob, harmless when unsupported.", + "effortLevel": "xhigh", + "env": { + "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000", + "FABLE_LOOP_THRESHOLD": "2" + }, + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/precompact-save-task.py", + "timeout": 10, + "statusMessage": "Saving original request verbatim before compaction..." + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/sessionstart-compact-recovery.py", + "timeout": 10, + "statusMessage": "Injecting compaction recovery: protocol + original request + git state..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/pretool-destructive-guard.py", + "timeout": 10 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/pretool-mem-privacy-guard.py", + "timeout": 10, + "statusMessage": "Privacy guard: scanning for work-markers before a global-corpus write..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/userpromptsubmit-mem-recall.py", + "timeout": 5, + "statusMessage": "Recalling cross-project memory..." + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/sessionend-mem-journal.py", + "timeout": 10, + "statusMessage": "Journaling session + incremental memory re-index..." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + }, + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-test-weakening-alarm.py", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/stop-claim-audit.py", + "timeout": 15, + "statusMessage": "Claim-audit gate..." + } + ] + } + ] + } +} diff --git a/claude/settings/settings-snippet-windows.json b/claude/settings/settings-snippet-windows.json new file mode 100644 index 0000000..2ef456a --- /dev/null +++ b/claude/settings/settings-snippet-windows.json @@ -0,0 +1,115 @@ +{ + "//": "Windows variant — merge into %USERPROFILE%\\.claude\\settings.json (install.ps1 prints instructions, it never edits your settings). Identical to settings-snippet.json except the hook commands run `python` (Windows Pythons ship no `python3`). Hook commands on Windows execute through Git Bash (Git for Windows), which expands the ~/ paths — see the README's Windows notes.", + "effortLevel": "xhigh", + "env": { + "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000" + }, + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/precompact-save-task.py", + "timeout": 10, + "statusMessage": "Saving original request verbatim before compaction..." + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/sessionstart-compact-recovery.py", + "timeout": 10, + "statusMessage": "Injecting compaction recovery: protocol + original request + git state..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/pretool-destructive-guard.py", + "timeout": 10 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/pretool-mem-privacy-guard.py", + "timeout": 10, + "statusMessage": "Privacy guard: scanning for work-markers before a global-corpus write..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/userpromptsubmit-mem-recall.py", + "timeout": 5, + "statusMessage": "Recalling cross-project memory..." + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/sessionend-mem-journal.py", + "timeout": 10, + "statusMessage": "Journaling session + incremental memory re-index..." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-loop-alarm.py", + "timeout": 10 + } + ] + }, + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/posttool-test-weakening-alarm.py", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python ~/.claude/hooks/stop-claim-audit.py", + "timeout": 15, + "statusMessage": "Claim-audit gate..." + } + ] + } + ] + } +} diff --git a/claude/skills/memory-search/SKILL.md b/claude/skills/memory-search/SKILL.md index 2d75c45..ad25e38 100644 --- a/claude/skills/memory-search/SKILL.md +++ b/claude/skills/memory-search/SKILL.md @@ -21,7 +21,7 @@ to re-derive from scratch may already be banked from another repo. Search first. - Routine edits where no cross-project lesson could change the answer. ## Commands -Run against the installed CLI (BASE-resolved — honors `CLAUDE_DIR`): +Run against the installed CLI (BASE-resolved — honors `CLAUDE_DIR`). On Windows, invoke `python` wherever a command below says `python3`: - `python3 ~/.claude/cli/mem.py search ""` — top hits (title + one-line description + path). Add `--json` for structured output, `--scope global` (or `project`) to isolate a scope. - `python3 ~/.claude/cli/mem.py show ` — read one memory's full body. - `python3 ~/.claude/cli/mem.py stats` — per-scope counts (sanity-check the corpus is indexed). diff --git a/claude/workflows/memory-gc.js b/claude/workflows/memory-gc.js index 67d2fdc..c8f4196 100644 --- a/claude/workflows/memory-gc.js +++ b/claude/workflows/memory-gc.js @@ -17,7 +17,7 @@ const dryRun = /--dry-run\b/.test(raw) // Every agent resolves the memory base the same way the CLI and hooks do — never // hardcode ~/.claude, so a scratch CLAUDE_DIR is honored. -const BASE_HINT = 'Resolve the memory base once: BASE = the value of the CLAUDE_DIR environment variable if it is set and non-empty, otherwise ~/.claude. Export CLAUDE_DIR into the environment of any mem.py call so the CLI reads the same base.' +const BASE_HINT = 'Resolve the memory base once: BASE = the value of the CLAUDE_DIR environment variable if it is set and non-empty, otherwise ~/.claude. Export CLAUDE_DIR into the environment of any mem.py call so the CLI reads the same base. On Windows, invoke `python` wherever a command below says `python3` (Windows Pythons ship no python3 launcher).' const SCAN = { type: 'object', diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..9cafc1c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,315 @@ +<# +.SYNOPSIS +fable-protocol installer for Windows — copies the framework into ~\.claude with backups. + +.DESCRIPTION +Native-Windows port of install.sh with full parity: out-of-tree backups, idempotent +re-runs, skill manifests with stale-file pruning, small-driver tier, strong-model +pinning. Never edits settings.json; prints the snippet to merge instead. + +Hook commands on Windows execute through Git Bash (Git for Windows), which Claude +Code also needs for its Bash tool — install it if you haven't. The Windows settings +snippets invoke `python` (Windows Pythons ship no `python3`). + +.PARAMETER Tier +'opus' (default) or 'small' — 'small' prints the small-driver settings snippet +(FABLE_LOOP_THRESHOLD=2, see docs/SUCCESSION.md). + +.PARAMETER StrongModel +Pin the verification agents (verifier, oracle, plan-critic) to this model, e.g. +-StrongModel opus — draft cheap, verify strong. Re-runs with the flag keep the pin. + +.EXAMPLE +powershell -ExecutionPolicy Bypass -File install.ps1 + +.EXAMPLE +pwsh ./install.ps1 -Tier small -StrongModel opus +#> +[CmdletBinding()] +param( + [ValidateSet('small', 'opus')] + [string]$Tier = 'opus', + [string]$StrongModel = '' +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +$Src = Join-Path $PSScriptRoot 'claude' +$Dst = if ($env:CLAUDE_DIR) { $env:CLAUDE_DIR } else { Join-Path $HOME '.claude' } +$Stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +# Backups live OUTSIDE the live agents/skills/... trees: a fable.bak-*/ directory +# left inside skills/ would itself be loaded by Claude Code as a duplicate skill. +$Bak = Join-Path (Join-Path $Dst 'fable-protocol-backups') $Stamp + +function Backup-Target([string]$Target, [string]$Rel) { + if (-not (Test-Path $Target)) { return } + $dest = Join-Path $Bak $Rel + $parent = Split-Path $dest -Parent + New-Item -ItemType Directory -Force -Path $parent | Out-Null + Copy-Item -Recurse -Force $Target $dest + Write-Host " backed up: $Target -> $dest" +} + +# True if $B exists and matches $A byte-for-byte (skip needless backups). +function Test-Identical([string]$A, [string]$B) { + if (-not (Test-Path $B -PathType Leaf)) { return $false } + return (Get-FileHash -Algorithm SHA256 $A).Hash -eq (Get-FileHash -Algorithm SHA256 $B).Hash +} + +# Relative ship-list of a skill dir in the exact bytes install.sh records: +# `./path/with/forward/slashes`, ordinal-sorted, one per line (LF). +function Get-ShipList([string]$Dir) { + $base = (Resolve-Path $Dir).Path + $list = @(Get-ChildItem -Recurse -File -LiteralPath $base | ForEach-Object { + './' + $_.FullName.Substring($base.Length).TrimStart('\', '/').Replace('\', '/') + }) + [Array]::Sort($list, [System.StringComparer]::Ordinal) + return $list +} + +function Write-LfFile([string]$Path, [string[]]$Lines) { + [System.IO.File]::WriteAllText($Path, (($Lines -join "`n") + "`n")) +} + +# True if every file the repo ships for this skill already matches the destination +# AND the recorded ship-list (.fable-manifest) is current. Files a USER added are +# ignored (and preserved); files a PREVIOUS kit version shipped are tracked in the +# manifest so upgrades can prune them. +function Test-SkillUnchanged([string]$SrcDir, [string]$DstDir) { + $manifest = Join-Path $DstDir '.fable-manifest' + if (-not (Test-Path $manifest -PathType Leaf)) { return $false } + $want = (Get-ShipList $SrcDir) -join "`n" + $have = ([System.IO.File]::ReadAllText($manifest)).Replace("`r`n", "`n").TrimEnd("`n") + if ($want -ne $have) { return $false } + $base = (Resolve-Path $SrcDir).Path + foreach ($f in Get-ChildItem -Recurse -File -LiteralPath $base) { + $rel = $f.FullName.Substring($base.Length).TrimStart('\', '/') + $t = Join-Path $DstDir $rel + if (-not (Test-Identical $f.FullName $t)) { return $false } + } + return $true +} + +# Remove files the manifest says a previous kit version shipped but this version no +# longer does. User files (never in a manifest) are untouched. Runs after backup. +function Remove-StaleSkillFiles([string]$SrcDir, [string]$DstDir) { + $manifest = Join-Path $DstDir '.fable-manifest' + if (-not (Test-Path $manifest -PathType Leaf)) { return } + foreach ($line in Get-Content $manifest) { + $rel = $line.Trim() -replace '^\./', '' + if (-not $rel) { continue } + if (-not (Test-Path (Join-Path $SrcDir $rel) -PathType Leaf)) { + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $DstDir $rel) + } + } +} + +# Agent markdown with a `model:` pin injected into the frontmatter when -StrongModel +# was given (asymmetric verification: draft cheap, verify strong). Skips injection +# if the frontmatter already pins a model. +function Get-RenderedAgent([string]$File) { + $lines = [System.IO.File]::ReadAllLines($File) + if ($StrongModel) { + $close = -1 + $pinned = $false + for ($i = 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -eq '---') { $close = $i; break } + if ($lines[$i] -match '^model:') { $pinned = $true } + } + if ($close -gt 0 -and -not $pinned) { + $lines = @($lines[0..($close - 1)]) + @("model: $StrongModel") + @($lines[$close..($lines.Count - 1)]) + } + } + return ($lines -join "`n") + "`n" +} + +# Best python launcher on this machine: py -3, then python, then python3. +# Returned as a command array (the leading comma stops PowerShell unrolling it). +function Get-PythonCommand { + foreach ($spec in 'py -3', 'python', 'python3') { + $probe = @($spec -split ' ') + $exe = Get-Command $probe[0] -ErrorAction SilentlyContinue + if (-not $exe) { continue } + try { + $pargs = @($probe | Select-Object -Skip 1) + '--version' + & $probe[0] @pargs *> $null + if ($LASTEXITCODE -eq 0) { return , $probe } + } catch { } + } + return $null +} + +$pinNote = if ($StrongModel) { " (verification agents pinned to model: $StrongModel)" } else { '' } +Write-Host "Installing fable-protocol into $Dst$pinNote" +foreach ($d in 'agents', 'workflows', 'skills', 'hooks') { + New-Item -ItemType Directory -Force -Path (Join-Path $Dst $d) | Out-Null +} + +foreach ($f in Get-ChildItem (Join-Path $Src 'agents') -Filter '*.md') { + $t = Join-Path (Join-Path $Dst 'agents') $f.Name + $rendered = Get-RenderedAgent $f.FullName + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("fable-agent-" + [System.IO.Path]::GetRandomFileName()) + [System.IO.File]::WriteAllText($tmp, $rendered) + try { + if (Test-Identical $tmp $t) { Write-Host " agent: $($f.Name) (unchanged)"; continue } + Backup-Target $t "agents/$($f.Name)" + Copy-Item -Force $tmp $t + Write-Host " agent: $($f.Name)" + } finally { + Remove-Item -Force -ErrorAction SilentlyContinue $tmp + } +} + +foreach ($f in Get-ChildItem (Join-Path $Src 'workflows') -Filter '*.js') { + $t = Join-Path (Join-Path $Dst 'workflows') $f.Name + if (Test-Identical $f.FullName $t) { Write-Host " workflow: /$($f.BaseName) (unchanged)"; continue } + Backup-Target $t "workflows/$($f.Name)" + Copy-Item -Force $f.FullName $t + Write-Host " workflow: /$($f.BaseName)" +} + +foreach ($d in Get-ChildItem (Join-Path $Src 'skills') -Directory) { + $t = Join-Path (Join-Path $Dst 'skills') $d.Name + if (Test-SkillUnchanged $d.FullName $t) { Write-Host " skill: $($d.Name) (unchanged)"; continue } + Backup-Target $t "skills/$($d.Name)" + Remove-StaleSkillFiles $d.FullName $t + New-Item -ItemType Directory -Force -Path $t | Out-Null + Copy-Item -Recurse -Force (Join-Path $d.FullName '*') $t + Write-LfFile (Join-Path $t '.fable-manifest') (Get-ShipList $d.FullName) + Write-Host " skill: $($d.Name)" +} + +foreach ($f in Get-ChildItem (Join-Path $Src 'hooks') -Filter '*.py') { + $t = Join-Path (Join-Path $Dst 'hooks') $f.Name + if (Test-Identical $f.FullName $t) { Write-Host " hook: $($f.Name) (unchanged)"; continue } + Backup-Target $t "hooks/$($f.Name)" + Copy-Item -Force $f.FullName $t + Write-Host " hook: $($f.Name)" +} + +# mem CLI + memory corpus — a component KIND of its own (see install.sh for the full +# rationale). privacy.toml is the USER'S pattern file: seed only when absent, NEVER +# overwrite work-markers the user has tuned. +New-Item -ItemType Directory -Force -Path (Join-Path $Dst 'cli'), (Join-Path $Dst 'memory') | Out-Null +$memSrc = Join-Path (Join-Path $Src 'cli') 'mem.py' +$memDst = Join-Path (Join-Path $Dst 'cli') 'mem.py' +if (Test-Identical $memSrc $memDst) { + Write-Host " cli: mem.py (unchanged)" +} else { + Backup-Target $memDst 'cli/mem.py' + Copy-Item -Force $memSrc $memDst + Write-Host " cli: mem.py" +} +$privacyDst = Join-Path (Join-Path $Dst 'memory') 'privacy.toml' +if (Test-Path $privacyDst) { + Write-Host " memory: privacy.toml (kept — your patterns are never overwritten)" +} else { + Copy-Item (Join-Path (Join-Path $Src 'memory') 'privacy.toml') $privacyDst + Write-Host " memory: privacy.toml (seeded — edit it to add your own work-markers)" +} + +# Bootstrap the memory index ONCE, now — so the first SessionEnd journal hook never +# has to carry a cold full build inside its 10s budget. Soft-fail: a missing python +# must not abort the install. +$py = Get-PythonCommand +if ($py) { + $prevClaudeDir = $env:CLAUDE_DIR + $env:CLAUDE_DIR = $Dst + try { + $pargs = @($py | Select-Object -Skip 1) + @($memDst, 'index', '--rebuild') + & $py[0] @pargs *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host " index: memory corpus indexed (mem index --rebuild)" + } else { + Write-Host " WARNING: initial 'mem index --rebuild' failed — recall warms up on first SessionEnd." + } + } finally { + if ($null -eq $prevClaudeDir) { Remove-Item Env:CLAUDE_DIR -ErrorAction SilentlyContinue } + else { $env:CLAUDE_DIR = $prevClaudeDir } + } +} else { + Write-Host " NOTE: no working Python found (tried py -3, python, python3) — skipped the" + Write-Host " initial memory index AND every hook will be inert until Python is installed." + Write-Host " Install from https://python.org (check 'Add python.exe to PATH')." +} + +# CLAUDE.md: never clobber an existing doctrine +$doctrineSrc = Join-Path $Src 'CLAUDE.md' +$doctrineDst = Join-Path $Dst 'CLAUDE.md' +if (Test-Path $doctrineDst) { + if (Test-Identical $doctrineSrc $doctrineDst) { + Write-Host " doctrine: CLAUDE.md (unchanged)" + } else { + Copy-Item -Force $doctrineSrc (Join-Path $Dst 'CLAUDE.fable-protocol.md') + Write-Host " NOTE: $doctrineDst already exists — wrote CLAUDE.fable-protocol.md next to it; merge manually." + } +} else { + Copy-Item $doctrineSrc $doctrineDst + Write-Host " doctrine: CLAUDE.md (edit the '## This machine' section for your box)" +} + +# Soft version check: saved workflows need Claude Code >= 2.1.154. +$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue +if ($claudeCmd) { + $verLine = (& claude --version 2>$null | Out-String) + if ($verLine -match '(\d+\.\d+\.\d+)') { + $ver = $Matches[1] + if ([version]$ver -lt [version]'2.1.154') { + Write-Host "" + Write-Host " WARNING: Claude Code $ver detected; saved workflows (/paranoid-review etc.)" + Write-Host " need >= 2.1.154. Everything else in the kit still works." + } + } +} else { + Write-Host "" + Write-Host " NOTE: 'claude' not found on PATH — could not verify Claude Code >= 2.1.154." +} + +$snippetName = if ($Tier -eq 'small') { 'settings-snippet-windows-small.json' } else { 'settings-snippet-windows.json' } +$snippet = Join-Path (Join-Path $Src 'settings') $snippetName +Write-Host "" +Write-Host "Last step (manual): merge this into $Dst\settings.json:" +Write-Host "----------------------------------------------------------------" +Write-Host ([System.IO.File]::ReadAllText($snippet).TrimEnd("`n")) +Write-Host "----------------------------------------------------------------" +if ($Tier -eq 'small') { + Write-Host "Small-driver tier (docs/SUCCESSION.md): FABLE_LOOP_THRESHOLD=2 — on a small model" + Write-Host "the SECOND identical failure is already the signal. Prefer the scripted workflows" + Write-Host "over free-form delegation, and run big work as /big-task (checkpointed" + Write-Host "steps, adversarial verify, commit every green)." + if (-not $StrongModel) { + Write-Host "TIP: re-run with -StrongModel to pin the" + Write-Host "verification agents — draft cheap, verify strong. Never let the checker be" + Write-Host "weaker than the drafter on work that matters." + } +} +Write-Host "effortLevel xhigh is the single biggest lever on Opus 4.8. The hook set:" +Write-Host " Stop claim-audit gate (benchmarked: bench/RESULTS.md)" +Write-Host " PreCompact + SessionStart(compact) save/inject the original request verbatim" +Write-Host " plus the actual git state after every compaction" +Write-Host " PreToolUse destructive-command guard (protects uncommitted work)" +if ($Tier -eq 'small') { + Write-Host " PostToolUse loop alarm (2nd identical failing command -> stop and reassess)" +} else { + Write-Host " PostToolUse loop alarm (3rd identical failing command -> stop and reassess)" +} +Write-Host " PostToolUse test-weakening alarm (skip/disable marker added to a test file)" +Write-Host " UserPromptSubmit cross-project memory recall (read-only mem CLI FTS query)" +Write-Host " SessionEnd session journal breadcrumb + incremental memory re-index" +Write-Host " PreToolUse memory privacy guard (blocks work-markers leaking into the global corpus)" +Write-Host "The mem CLI (cross-project memory, layered on native auto-memory):" +Write-Host " python $Dst\cli\mem.py search|show|stats|doctor|gc-scan (corpus: $Dst\memory\)" +Write-Host "" +Write-Host "Windows notes:" +Write-Host " - Hook commands run through Git Bash (Git for Windows) — Claude Code needs it" +Write-Host " for its Bash tool anyway; without it the hooks above are inert." +Write-Host " - The snippet invokes 'python' (Windows Pythons ship no 'python3'); make sure" +Write-Host " 'python --version' works in Git Bash. Using only the 'py' launcher? Replace" +Write-Host " 'python ' with 'py -3 ' in every hook command when you merge." +Write-Host "" +Write-Host "After merging, verify the install deterministically:" +Write-Host " powershell -ExecutionPolicy Bypass -File tools\doctor.ps1" +Write-Host "" +Write-Host "Done. Start a new Claude Code session and ask: 'quote the first bullet of your" +Write-Host "Evidence before claims doctrine' to confirm the load." diff --git a/tests/test_install_doctor.py b/tests/test_install_doctor.py index b04b62f..5ef5111 100644 --- a/tests/test_install_doctor.py +++ b/tests/test_install_doctor.py @@ -6,6 +6,12 @@ import subprocess from pathlib import Path +import pytest + +# The POSIX installer path; Windows covers install.ps1/doctor.ps1 in +# test_windows_port.py instead of driving bash scripts through Git Bash. +pytestmark = pytest.mark.skipif(os.name == "nt", reason="POSIX installer path") + ROOT = Path(__file__).resolve().parents[1] INSTALL = ROOT / "install.sh" DOCTOR = ROOT / "tools" / "doctor.sh" diff --git a/tests/test_mem_privacy_guard.py b/tests/test_mem_privacy_guard.py index d1ad03b..491a945 100644 --- a/tests/test_mem_privacy_guard.py +++ b/tests/test_mem_privacy_guard.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +import pytest + HOOK = Path(__file__).resolve().parents[1] / "claude" / "hooks" / "pretool-mem-privacy-guard.py" @@ -103,6 +105,10 @@ def test_case_insensitive_fs_variant_path_is_blocked(tmp_path): assert "MEMORY PRIVACY GUARD" in r.stderr +@pytest.mark.skipif(os.name == "nt", reason=( + "a genuinely distinct Memory/ sibling cannot be constructed on NTFS: realpath " + "case-folds Memory/ -> memory/ before the guard compares, and blocking IS " + "correct there — the env override models the fs, it cannot overrule it")) def test_case_sensitive_fs_variant_path_is_allowed(tmp_path): # With case-sensitivity forced off, $BASE/Memory/ is a genuinely distinct dir and # must NOT be blocked (no false-block of a real sibling on Linux). diff --git a/tests/test_small_tier.py b/tests/test_small_tier.py index 0887354..af81ae0 100644 --- a/tests/test_small_tier.py +++ b/tests/test_small_tier.py @@ -1,12 +1,20 @@ """Small-driver tier: snippet sync, installer flags, model pinning. Self-contained.""" import json +import os import pathlib import subprocess +import pytest + REPO = pathlib.Path(__file__).resolve().parent.parent BASE = json.loads((REPO / "claude/settings/settings-snippet.json").read_text()) SMALL = json.loads((REPO / "claude/settings/settings-snippet-small.json").read_text()) +# The POSIX installer path: on Windows, bare `bash` is the WSL stub (which even +# fails with a nonzero exit — a false green for the unknown-flag test). Windows +# exercises the same flags against install.ps1 in test_windows_port.py. +requires_bash = pytest.mark.skipif(os.name == "nt", reason="POSIX installer path") + def test_small_snippet_is_base_plus_loop_threshold(): """The small snippet must never drift from the base except the documented delta.""" @@ -28,12 +36,14 @@ def run_install(tmp_path, *flags): return dst, r.stdout +@requires_bash def test_default_install_does_not_pin_models(tmp_path): dst, _ = run_install(tmp_path) for agent in (dst / "agents").glob("*.md"): assert "\nmodel:" not in agent.read_text(), f"{agent.name} unexpectedly pinned" +@requires_bash def test_strong_model_flag_pins_all_agent_frontmatter(tmp_path): dst, _ = run_install(tmp_path, "--strong-model", "opus") agents = list((dst / "agents").glob("*.md")) @@ -44,6 +54,7 @@ def test_strong_model_flag_pins_all_agent_frontmatter(tmp_path): assert "\nmodel: opus\n" in fm, f"{agent.name} frontmatter not pinned: {fm!r}" +@requires_bash def test_pinned_install_is_idempotent(tmp_path): dst, _ = run_install(tmp_path, "--strong-model", "opus") before = {p.name: p.read_text() for p in (dst / "agents").glob("*.md")} @@ -56,12 +67,14 @@ def test_pinned_install_is_idempotent(tmp_path): assert not backups.exists() or not any(backups.iterdir()), "idempotent re-run made backups" +@requires_bash def test_tier_small_prints_small_snippet(tmp_path): _, out = run_install(tmp_path, "--tier", "small") assert "FABLE_LOOP_THRESHOLD" in out, "--tier small must print the small snippet" assert "big-task" in out, "small-tier guidance should point at /big-task" +@requires_bash def test_unknown_flag_fails_loudly(tmp_path): r = subprocess.run( ["bash", str(REPO / "install.sh"), "--bogus"], diff --git a/tests/test_webdesign_skill.py b/tests/test_webdesign_skill.py index 6e50d49..832834a 100644 --- a/tests/test_webdesign_skill.py +++ b/tests/test_webdesign_skill.py @@ -8,11 +8,17 @@ import subprocess from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] SKILL = ROOT / "claude" / "skills" / "webdesign" INSTALL = ROOT / "install.sh" DOCTOR = ROOT / "tools" / "doctor.sh" +# The POSIX installer path; Windows exercises the same behaviors against +# install.ps1/doctor.ps1 in test_windows_port.py. +requires_bash = pytest.mark.skipif(os.name == "nt", reason="POSIX installer path") + def run(script, claude_dir, state_dir=None): env = dict(os.environ, CLAUDE_DIR=str(claude_dir)) @@ -56,7 +62,8 @@ def test_skill_points_at_its_references(): def test_german_market_carries_the_laws(): # The compliance gate is only as good as the laws it names. These are the # load-bearing ones as of mid-2026 (verified against live sources in v1.7). - text = (SKILL / "references" / "german-market.md").read_text() + # encoding pinned: Windows' locale default (cp1252) would mojibake the umlaut + text = (SKILL / "references" / "german-market.md").read_text(encoding="utf-8") for token in ("DDG", "DSGVO", "TDDDG", "BFSG", "Impressum", "Datenschutzerklärung", "prefers-reduced-motion"): assert token in text, f"german-market.md must carry '{token}'" @@ -84,6 +91,7 @@ def test_doctrine_routes_design_work_to_the_skill(): assert "webdesign" in doctrine, "doctrine must route website work to the skill" +@requires_bash def test_install_copies_the_full_skill_dir(tmp_path): # Regression for the v1.7 installer change: skills used to install as bare # SKILL.md — references/ would silently not arrive. @@ -101,6 +109,7 @@ def test_install_copies_the_full_skill_dir(tmp_path): assert not (claude / "fable-protocol-backups").exists(), "idempotent re-run must not churn backups" +@requires_bash def test_install_preserves_user_extra_files_in_skill_dir(tmp_path): # A user's own notes inside an installed skill dir must survive re-install # and must not break idempotency detection for shipped files. @@ -115,6 +124,7 @@ def test_install_preserves_user_extra_files_in_skill_dir(tmp_path): assert extra.read_text() == "mine\n" +@requires_bash def test_install_prunes_formerly_shipped_skill_files(tmp_path): # A file an OLDER kit version shipped (tracked in .fable-manifest) but the # current version doesn't must be pruned on upgrade — a stale legal checklist @@ -138,6 +148,7 @@ def test_install_prunes_formerly_shipped_skill_files(tmp_path): assert "old-checklist" not in manifest.read_text() +@requires_bash def test_doctor_fails_on_missing_skill_reference(tmp_path): # Regression for the v1.7 doctor change: a skill missing its reference docs # used to pass (only SKILL.md was checked). diff --git a/tests/test_windows_port.py b/tests/test_windows_port.py new file mode 100644 index 0000000..a5da1c9 --- /dev/null +++ b/tests/test_windows_port.py @@ -0,0 +1,252 @@ +# Windows-port guards. The kit's Windows surface is a PARALLEL implementation +# (install.ps1 / doctor.ps1 / *-windows*.json snippets) of behavior the POSIX +# scripts already test — parallel implementations drift silently, so every test +# here either pins the Windows artifact to its POSIX twin or exercises the +# PowerShell scripts end-to-end the same way test_install_doctor.py does bash. +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SETTINGS = ROOT / "claude" / "settings" +INSTALL_PS = ROOT / "install.ps1" +DOCTOR_PS = ROOT / "tools" / "doctor.ps1" +PWSH = shutil.which("pwsh") + +# unix snippet -> windows snippet: same kit, different python launcher spelling. +PAIRS = [ + ("settings-snippet.json", "settings-snippet-windows.json"), + ("settings-snippet-small.json", "settings-snippet-windows-small.json"), +] + + +def load(name): + return json.loads((SETTINGS / name).read_text()) + + +def without_comment(snippet): + return {k: v for k, v in snippet.items() if k != "//"} + + +# ---- snippet consistency (runs everywhere) ---- + +def test_windows_snippets_mirror_unix(): + # The Windows snippets must be the Unix snippets with `python3 ` -> `python ` + # in hook commands and nothing else different (comment aside). Any drift means + # Windows installs silently diverge from the tested configuration. + for unix_name, win_name in PAIRS: + unix = without_comment(load(unix_name)) + win = without_comment(load(win_name)) + normalized = json.loads( + json.dumps(unix).replace("python3 ~/.claude/hooks/", "python ~/.claude/hooks/")) + assert normalized == win, f"{win_name} drifted from {unix_name}" + + +def test_windows_snippets_never_invoke_python3(): + # Windows Pythons ship no `python3` launcher — a python3 command in the + # Windows snippet is a hook that never fires. + for _, win_name in PAIRS: + for groups in load(win_name)["hooks"].values(): + for group in groups: + for hook in group["hooks"]: + assert "python3" not in hook["command"], \ + f"{win_name}: {hook['command']}" + + +def test_windows_small_variant_sets_loop_threshold(): + small = load("settings-snippet-windows-small.json") + assert small["env"]["FABLE_LOOP_THRESHOLD"] == "2" + assert "FABLE_LOOP_THRESHOLD" not in load("settings-snippet-windows.json").get("env", {}) + + +# ---- install.ps1 / doctor.ps1 end-to-end (needs pwsh; CI has it on both OSes) ---- + +requires_pwsh = pytest.mark.skipif(PWSH is None, reason="pwsh not on PATH") +requires_bash_too = pytest.mark.skipif( + PWSH is None or os.name == "nt", reason="needs pwsh AND the POSIX installer") + + +def run_ps(script, claude_dir, *args, state_dir=None): + env = dict(os.environ, CLAUDE_DIR=str(claude_dir)) + if state_dir: + env["FABLE_STATE_DIR"] = str(state_dir) + return subprocess.run([PWSH, "-NoProfile", "-File", str(script), *args], + capture_output=True, text=True, timeout=180, env=env) + + +def run_sh(script, claude_dir): + return subprocess.run(["bash", str(script)], capture_output=True, text=True, timeout=60, + env=dict(os.environ, CLAUDE_DIR=str(claude_dir))) + + +def install_and_merge_settings(claude_dir): + r = run_ps(INSTALL_PS, claude_dir) + assert r.returncode == 0, r.stdout + r.stderr + snippet = load("settings-snippet-windows.json") + (claude_dir / "settings.json").write_text(json.dumps(snippet)) + + +@requires_pwsh +def test_install_ps1_installs_everything_idempotently(tmp_path): + claude = tmp_path / "claude" + r = run_ps(INSTALL_PS, claude) + assert r.returncode == 0, r.stdout + r.stderr + for rel in ("hooks/stop-claim-audit.py", "agents/verifier.md", + "workflows/paranoid-review.js", "skills/fable/SKILL.md", + "skills/webdesign/references/german-market.md", + "cli/mem.py", "memory/privacy.toml", "CLAUDE.md"): + assert (claude / rel).is_file(), f"{rel} did not install" + # Second run: nothing changed, nothing backed up (same invariant as install.sh). + r2 = run_ps(INSTALL_PS, claude) + assert r2.returncode == 0, r2.stdout + r2.stderr + assert "webdesign (unchanged)" in r2.stdout + assert not (claude / "fable-protocol-backups").exists(), \ + "idempotent re-run must not churn backups" + # Backups must never create loadable duplicates inside skills/. + shipped = {p.name for p in (ROOT / "claude" / "skills").iterdir() if p.is_dir()} + installed = {p.name for p in (claude / "skills").iterdir()} + assert installed == shipped + + +@requires_pwsh +def test_install_ps1_preserves_user_extra_files_in_skill_dir(tmp_path): + claude = tmp_path / "claude" + assert run_ps(INSTALL_PS, claude).returncode == 0 + extra = claude / "skills" / "webdesign" / "references" / "my-notes.md" + extra.write_text("mine\n") + r2 = run_ps(INSTALL_PS, claude) + assert r2.returncode == 0 + assert "webdesign (unchanged)" in r2.stdout + assert extra.read_text() == "mine\n" + + +@requires_pwsh +def test_install_ps1_prunes_formerly_shipped_skill_files(tmp_path): + claude = tmp_path / "claude" + assert run_ps(INSTALL_PS, claude).returncode == 0 + sk = claude / "skills" / "webdesign" + stale = sk / "references" / "old-checklist.md" + stale.write_text("outdated legal advice\n") + manifest = sk / ".fable-manifest" + manifest.write_text(manifest.read_text() + "./references/old-checklist.md\n") + mine = sk / "references" / "my-notes.md" + mine.write_text("mine\n") + r2 = run_ps(INSTALL_PS, claude) + assert r2.returncode == 0, r2.stdout + r2.stderr + assert not stale.exists(), "manifest-tracked stale file must be pruned on upgrade" + assert mine.read_text() == "mine\n", "user file must survive the upgrade" + assert "old-checklist" not in manifest.read_text() + + +@requires_pwsh +def test_install_ps1_tier_small_prints_small_snippet(tmp_path): + claude = tmp_path / "claude" + r = run_ps(INSTALL_PS, claude, "-Tier", "small") + assert r.returncode == 0, r.stdout + r.stderr + assert "FABLE_LOOP_THRESHOLD" in r.stdout, "-Tier small must print the small snippet" + assert "big-task" in r.stdout, "small-tier guidance should point at /big-task" + + +@requires_pwsh +def test_install_ps1_unknown_flag_fails_loudly(tmp_path): + r = run_ps(INSTALL_PS, tmp_path / "claude", "-Bogus") + assert r.returncode != 0 + + +@requires_pwsh +def test_install_ps1_default_does_not_pin_models(tmp_path): + claude = tmp_path / "claude" + assert run_ps(INSTALL_PS, claude).returncode == 0 + for agent in (claude / "agents").glob("*.md"): + assert "\nmodel:" not in agent.read_text(), f"{agent.name} unexpectedly pinned" + + +@requires_pwsh +def test_install_ps1_strong_model_pins_verification_agents(tmp_path): + claude = tmp_path / "claude" + r = run_ps(INSTALL_PS, claude, "-StrongModel", "opus") + assert r.returncode == 0, r.stdout + r.stderr + for agent in ("verifier", "oracle", "plan-critic"): + text = (claude / "agents" / f"{agent}.md").read_text() + frontmatter = text.split("---")[1] + assert "model: opus" in frontmatter, f"{agent} not pinned" + + +@requires_pwsh +def test_doctor_ps1_passes_on_full_install(tmp_path): + claude = tmp_path / "claude" + install_and_merge_settings(claude) + r = run_ps(DOCTOR_PS, claude, state_dir=tmp_path / "state") + assert r.returncode == 0, r.stdout + r.stderr + assert "DOCTOR: installation verified" in r.stdout + assert "FAIL" not in r.stdout + + +@requires_pwsh +def test_doctor_ps1_fails_without_settings_merge(tmp_path): + # The exact real-world failure the doctor exists for: install ran, the + # manual settings merge did not — every hook silently unwired. + claude = tmp_path / "claude" + assert run_ps(INSTALL_PS, claude).returncode == 0 + r = run_ps(DOCTOR_PS, claude, state_dir=tmp_path / "state") + assert r.returncode == 1 + assert "settings.json missing" in r.stdout + + +@requires_pwsh +def test_doctor_ps1_fails_on_unwired_hook(tmp_path): + claude = tmp_path / "claude" + install_and_merge_settings(claude) + settings = json.loads((claude / "settings.json").read_text()) + del settings["hooks"]["Stop"] # un-wire the benchmarked gate + (claude / "settings.json").write_text(json.dumps(settings)) + r = run_ps(DOCTOR_PS, claude, state_dir=tmp_path / "state") + assert r.returncode == 1 + assert "stop-claim-audit.py" in r.stdout and "NOT wired" in r.stdout + + +@requires_pwsh +def test_doctor_ps1_warns_on_python3_settings(tmp_path): + # A Windows user who merged the UNIX snippet has hooks that never fire — + # the doctor must at least say so. + claude = tmp_path / "claude" + assert run_ps(INSTALL_PS, claude).returncode == 0 + (claude / "settings.json").write_text(json.dumps(load("settings-snippet.json"))) + r = run_ps(DOCTOR_PS, claude, state_dir=tmp_path / "state") + assert "invokes 'python3'" in r.stdout + + +# ---- cross-tool parity: a machine that uses both installers must not churn ---- + +@requires_bash_too +def test_ps1_install_is_byte_compatible_with_bash_install(tmp_path): + sh_dir, ps_dir = tmp_path / "sh", tmp_path / "ps" + assert run_sh(ROOT / "install.sh", sh_dir).returncode == 0 + assert run_ps(INSTALL_PS, ps_dir).returncode == 0 + # Same skill manifests byte-for-byte (format AND ordering). + for d in (ROOT / "claude" / "skills").iterdir(): + a = (sh_dir / "skills" / d.name / ".fable-manifest").read_bytes() + b = (ps_dir / "skills" / d.name / ".fable-manifest").read_bytes() + assert a == b, f"manifest for {d.name} differs between installers" + # install.ps1 over a bash install: everything reads as unchanged. + r = run_ps(INSTALL_PS, sh_dir) + assert r.returncode == 0 + assert "backed up" not in r.stdout, "ps1 over a bash install must not churn backups" + + +@requires_bash_too +def test_ps1_strong_model_output_matches_bash(tmp_path): + sh_dir, ps_dir = tmp_path / "sh", tmp_path / "ps" + r = subprocess.run(["bash", str(ROOT / "install.sh"), "--strong-model", "opus"], + capture_output=True, text=True, timeout=60, + env=dict(os.environ, CLAUDE_DIR=str(sh_dir))) + assert r.returncode == 0 + assert run_ps(INSTALL_PS, ps_dir, "-StrongModel", "opus").returncode == 0 + for agent in ("verifier", "oracle", "plan-critic"): + a = (sh_dir / "agents" / f"{agent}.md").read_bytes() + b = (ps_dir / "agents" / f"{agent}.md").read_bytes() + assert a == b, f"pinned {agent}.md differs between installers" diff --git a/tools/doctor.ps1 b/tools/doctor.ps1 new file mode 100644 index 0000000..c8d712c --- /dev/null +++ b/tools/doctor.ps1 @@ -0,0 +1,230 @@ +<# +.SYNOPSIS +fable-protocol doctor for Windows — verifies an installation is actually live, not silently inert. + +.DESCRIPTION +Native-Windows port of tools/doctor.sh with the same checks and exit semantics. +The kit's weakest link is the one manual step: merging the settings snippet. A +botched merge leaves every hook unwired and the whole kit inert with zero +symptoms — the exact failure the kit exists to prevent. This script makes the +check deterministic. Run it after install.ps1 (and after Claude Code updates). + +Exit 0 = installation verified; exit 1 = at least one FAIL line above. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Continue' + +$Src = Join-Path (Split-Path $PSScriptRoot -Parent) 'claude' +$Dst = if ($env:CLAUDE_DIR) { $env:CLAUDE_DIR } else { Join-Path $HOME '.claude' } +$script:fail = 0 + +function Ok([string]$Msg) { Write-Host " ok: $Msg" } +function Bad([string]$Msg) { Write-Host " FAIL: $Msg"; $script:fail = 1 } +function Warn([string]$Msg) { Write-Host " warn: $Msg" } + +function Test-Identical([string]$A, [string]$B) { + if (-not (Test-Path $B -PathType Leaf)) { return $false } + return (Get-FileHash -Algorithm SHA256 $A).Hash -eq (Get-FileHash -Algorithm SHA256 $B).Hash +} + +# Best python launcher on this machine: py -3, then python, then python3. +# Returned as a command array (the leading comma stops PowerShell unrolling it). +function Get-PythonCommand { + foreach ($spec in 'py -3', 'python', 'python3') { + $probe = @($spec -split ' ') + $exe = Get-Command $probe[0] -ErrorAction SilentlyContinue + if (-not $exe) { continue } + try { + $pargs = @($probe | Select-Object -Skip 1) + '--version' + & $probe[0] @pargs *> $null + if ($LASTEXITCODE -eq 0) { return , $probe } + } catch { } + } + return $null +} + +function Invoke-Python($Py, [string[]]$PyArgs) { + $Py = @($Py) + $pargs = @($Py | Select-Object -Skip 1) + $PyArgs + & $Py[0] @pargs 2>$null +} + +Write-Host "fable-protocol doctor — checking $Dst" + +# 1. Python — every hook runs through it. The Windows snippets invoke `python`. +$py = Get-PythonCommand +if ($py) { + $ver = (Invoke-Python $py @('--version') | Out-String).Trim() + Ok "python on PATH ($ver via '$($py -join ' ')')" + if ($py[0] -ne 'python') { + Warn "'python' itself is not the working launcher — the settings snippet invokes 'python'; adjust the hook commands to '$($py -join ' ')' when you merge" + } +} else { + Bad "no working Python found (tried py -3, python, python3) — every hook is inert" +} + +# 1b. Windows only: hook commands execute through Git Bash (Git for Windows). +if ($env:OS -eq 'Windows_NT') { + if (Get-Command bash -ErrorAction SilentlyContinue) { + Ok "Git Bash on PATH (hook command shell)" + } else { + Bad "bash not on PATH — on Windows, hook commands run through Git Bash; install Git for Windows (https://gitforwindows.org) or every hook is inert" + } +} + +# 2. Every component the repo ships is installed (and hooks compile). +foreach ($f in Get-ChildItem (Join-Path $Src 'hooks') -Filter '*.py') { + $t = Join-Path (Join-Path $Dst 'hooks') $f.Name + if (-not (Test-Path $t -PathType Leaf)) { + Bad "hook missing: $t (re-run install.ps1)" + } elseif ($py) { + Invoke-Python $py @('-m', 'py_compile', $t) | Out-Null + if ($LASTEXITCODE -ne 0) { + Bad "hook does not compile: $t" + } elseif (-not (Test-Identical $f.FullName $t)) { + Warn "hook differs from this repo checkout: $t (older kit version?)" + } else { + Ok "hook: $($f.Name)" + } + } elseif (-not (Test-Identical $f.FullName $t)) { + Warn "hook differs from this repo checkout: $t (older kit version?)" + } else { + Ok "hook: $($f.Name)" + } +} +foreach ($f in Get-ChildItem (Join-Path $Src 'agents') -Filter '*.md') { + if (Test-Path (Join-Path (Join-Path $Dst 'agents') $f.Name) -PathType Leaf) { Ok "agent: $($f.Name)" } + else { Bad "agent missing: $(Join-Path (Join-Path $Dst 'agents') $f.Name)" } +} +foreach ($f in Get-ChildItem (Join-Path $Src 'workflows') -Filter '*.js') { + if (Test-Path (Join-Path (Join-Path $Dst 'workflows') $f.Name) -PathType Leaf) { Ok "workflow: /$($f.BaseName)" } + else { Bad "workflow missing: $(Join-Path (Join-Path $Dst 'workflows') $f.Name)" } +} +foreach ($d in Get-ChildItem (Join-Path $Src 'skills') -Directory) { + $complete = $true + $drifted = $false + $base = $d.FullName + foreach ($f in Get-ChildItem -Recurse -File -LiteralPath $base) { + $rel = $f.FullName.Substring($base.Length).TrimStart('\', '/') + $t = Join-Path (Join-Path (Join-Path $Dst 'skills') $d.Name) $rel + if (-not (Test-Path $t -PathType Leaf)) { + Bad "skill file missing: $t"; $complete = $false + } elseif (-not (Test-Identical $f.FullName $t)) { + Warn "skill file differs from this repo checkout: $t (older kit version?)"; $drifted = $true + } + } + if ($complete -and -not $drifted) { Ok "skill: $($d.Name)" } +} + +# 2b. The mem CLI is a component KIND of its own — the four globs above don't see +# claude/cli/, so it gets a hand-written check: present, compiles, and its own +# self-diagnostic runs clean, reporting its FTS mode (fts5 / degraded-like). +$mem = Join-Path (Join-Path $Dst 'cli') 'mem.py' +if (-not (Test-Path $mem -PathType Leaf)) { + Bad "mem CLI missing: $mem (re-run install.ps1)" +} elseif ($py) { + Invoke-Python $py @('-m', 'py_compile', $mem) | Out-Null + if ($LASTEXITCODE -ne 0) { + Bad "mem CLI does not compile: $mem" + } else { + $prevClaudeDir = $env:CLAUDE_DIR + $env:CLAUDE_DIR = $Dst + try { + $out = Invoke-Python $py @($mem, 'doctor') | Out-String + $rc = $LASTEXITCODE + } finally { + if ($null -eq $prevClaudeDir) { Remove-Item Env:CLAUDE_DIR -ErrorAction SilentlyContinue } + else { $env:CLAUDE_DIR = $prevClaudeDir } + } + $mode = 'unknown' + foreach ($line in ($out -split "`r?`n")) { + if ($line -match '^mode=(.+)$') { $mode = $Matches[1]; break } + } + if ($rc -eq 0) { Ok "mem CLI (mode=$mode)" } + else { Bad "mem CLI self-check failed: (with CLAUDE_DIR=$Dst) python $mem doctor" } + } +} + +# Memory corpus dir writable + privacy pattern seed present. +$memDir = Join-Path $Dst 'memory' +try { + New-Item -ItemType Directory -Force -Path $memDir | Out-Null + $probe = Join-Path $memDir '.doctor-probe' + New-Item -ItemType File -Force -Path $probe | Out-Null + Remove-Item -Force $probe + Ok "memory dir writable: $memDir" +} catch { + Bad "memory dir not writable: $memDir — recall + journal re-indexing will be inert" +} +if (Test-Path (Join-Path $memDir 'privacy.toml') -PathType Leaf) { + Ok "privacy.toml present" +} else { + Warn "privacy.toml missing in $memDir — the privacy guard has no patterns to match (fails open)" +} + +# 3. Doctrine is loadable. +$doctrine = Join-Path $Dst 'CLAUDE.md' +$doctrineText = if (Test-Path $doctrine -PathType Leaf) { [System.IO.File]::ReadAllText($doctrine) } else { '' } +if ($doctrineText.Contains('Evidence before claims')) { + Ok "doctrine present in CLAUDE.md" + if ($doctrineText.Contains('Replace with 3-6 lines')) { + Warn "the '## This machine' section is still the placeholder — fill it in" + } +} elseif (Test-Path (Join-Path $Dst 'CLAUDE.fable-protocol.md') -PathType Leaf) { + Bad "doctrine NOT merged: it sits unloaded in CLAUDE.fable-protocol.md next to your CLAUDE.md" +} else { + Bad "doctrine missing: no Evidence-before-claims section in $doctrine" +} + +# 4. The manual step: settings.json actually wires the hooks. +$settingsPath = Join-Path $Dst 'settings.json' +if (-not (Test-Path $settingsPath -PathType Leaf)) { + Bad "settings.json missing — no hooks are wired, the enforcement layer is OFF" +} else { + $settingsText = [System.IO.File]::ReadAllText($settingsPath) + $settings = $null + try { $settings = $settingsText | ConvertFrom-Json } catch { } + if ($null -eq $settings) { + Bad "settings.json is not valid JSON — Claude Code will ignore it" + } else { + Ok "settings.json parses" + foreach ($f in Get-ChildItem (Join-Path $Src 'hooks') -Filter '*.py') { + if ($settingsText.Contains($f.Name)) { Ok "wired: $($f.Name)" } + else { Bad "NOT wired in settings.json: $($f.Name) (merge the snippet from install.ps1)" } + } + $effort = $null + try { $effort = $settings.effortLevel } catch { } + if ($effort -eq 'xhigh') { + Ok "effortLevel: xhigh (the single biggest lever)" + } else { + Warn "effortLevel is not 'xhigh' in settings.json — on Opus 4.8 this is THE lever" + } + if ($settingsText.Contains('python3 ')) { + Warn "settings.json invokes 'python3' — Windows Pythons ship no 'python3'; use the Windows snippet (python) or those hooks are inert" + } + } +} + +# 5. Hook state dir is writable (loop alarm, weakening alarm, compaction save). +$state = if ($env:FABLE_STATE_DIR) { $env:FABLE_STATE_DIR } else { Join-Path (Join-Path $Dst 'tmp') 'fable-protocol' } +try { + New-Item -ItemType Directory -Force -Path $state | Out-Null + $probe = Join-Path $state '.doctor-probe' + New-Item -ItemType File -Force -Path $probe | Out-Null + Remove-Item -Force $probe + Ok "state dir writable: $state" +} catch { + Bad "state dir not writable: $state — stateful hooks (loop alarm, compaction save) will be inert" +} + +Write-Host "" +if ($script:fail -ne 0) { + Write-Host "DOCTOR: FAILED — fix the FAIL lines above, then re-run." + exit 1 +} +Write-Host "DOCTOR: installation verified. Final live check (needs a real session):" +Write-Host " ask a fresh session to 'quote the first bullet of your Evidence before claims doctrine'." +exit 0