From a330a3506163cb511fa51dbc6fb8d46bcaf82fcc Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 13:21:44 +0000 Subject: [PATCH 1/5] Record post-flip public-beta status: source public, only the prerelease tag pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository at github.com/getaskclaw/fable-session is public with private vulnerability reporting enabled and verified, branch-protected main, and every pre-publication verification green on merge commit efff5724 (PR #1). Move the release-status docs and the offline readiness policy from the pre-flip phase contract to the post-flip public-beta contract: - PUBLIC_RELEASE_CHECKLIST.md: drop the stale BLOCKED/private wording; mark visibility, private vulnerability reporting, branch protection, and all pre-publication verification items resolved with concise evidence; keep the permanent never-push-the-old-history warning; keep exactly one unchecked item — pushing the v0.3.0-beta.1 prerelease tag after this status PR merges and protected-main CI passes. - README.md: status section now says the repository is public, reporting is enabled, and the source-level gates are complete, without claiming the GitHub prerelease exists before the tag workflow succeeds; pre-PyPI install warnings unchanged. - tests/public_readiness_check.py: enforce the canonical post-flip wording offline — reject regressions to blocked/private/unchecked operational claims, require checked visibility/reporting/protection records and the sole pending-tag item; license, URL, clean-history, credential, internal-identifier, SECURITY, no-bypass and history-gate checks preserved. - tests/test_public_readiness.py, tests/test_mit_release.py: pre-flip contract tests and stale private-repository comments updated to the post-flip contract, with new checker-guard regression tests. SECURITY.md needed no change: its reporting path was already publication-independent and carries no private-repository claim. No runtime code, workflows, packaging, version, or release-gate changes. Co-Authored-By: Claude Fable 5 --- PUBLIC_RELEASE_CHECKLIST.md | 96 +++++++++------- README.md | 17 +-- tests/public_readiness_check.py | 124 ++++++++++++++------ tests/test_mit_release.py | 195 ++++++++++++++++++++++---------- tests/test_public_readiness.py | 27 +++-- 5 files changed, 310 insertions(+), 149 deletions(-) diff --git a/PUBLIC_RELEASE_CHECKLIST.md b/PUBLIC_RELEASE_CHECKLIST.md index bbd53a0..64ee50f 100644 --- a/PUBLIC_RELEASE_CHECKLIST.md +++ b/PUBLIC_RELEASE_CHECKLIST.md @@ -1,8 +1,12 @@ # Public release checklist -**Status: public release is BLOCKED.** Every unchecked item below is an -independent blocker. Do not publish a package or flip the repository -public until all of them are resolved and re-verified. +**Status: the source is PUBLIC.** The official repository at +`https://github.com/getaskclaw/fable-session` is public, and every +source-level public-beta gate below is resolved on the reviewed history +ending at merge commit `efff5724` (PR #1, the 0.3.0b1 release +candidate). The single remaining operational action is pushing the +`v0.3.0-beta.1` prerelease tag, and that happens only after this status +PR merges and protected-main CI passes on the merge commit. ## Resolved @@ -17,12 +21,27 @@ public until all of them are resolved and re-verified. carries the homepage/repository/issues URLs. The CI and release workflows stay deliberately URL-free. - [x] **Clean-history export and independent review: RESOLVED.** The - official private repository now holds exactly one independently - reviewed clean root commit (`4ead2832…`, the reviewed 0.2.0 tree, - produced as an orphan export). The full-history reference scan - reports zero hits on that history, and this checkout's `origin` - remote is a read-only local bundle of that same reviewed root — - release work builds on top of it, never beside it. + official repository holds exactly one independently reviewed clean + root commit (`4ead2832…`, the reviewed 0.2.0 tree, produced as an + orphan export) with only reviewed release work on top of it. The + full-history reference scan reports zero hits on every commit + reachable from merge commit `efff5724` — the exact history that + was published. +- [x] **Repository visibility: RESOLVED — public.** All code, history, + and test work (the 0.3.0b1 release candidate included) was + finished and re-verified on the release history first; the + repository was then made public as the deliberate final step. + Public `main` is exactly the reviewed history ending at merge + commit `efff5724`. +- [x] **Private vulnerability reporting: RESOLVED — enabled and + verified.** Enabled immediately after the visibility flip (it is a + public-repository setting and could not be switched on earlier); + the "Report a vulnerability" button is confirmed visible under the + repository's Security tab, so `SECURITY.md`'s reporting path has + been usable from the first public minute. +- [x] **Branch protection: RESOLVED.** `main` is protected: release + work lands only through pull requests with the CI gates required + to pass (PR #1 merged that way), never through direct pushes. ## Standing warning — the OLD history @@ -35,42 +54,39 @@ of it may ever be published; `tests/full_history_readiness_check.py` verifies exactly that property on the history that is actually being published. -## Remaining blockers - -- [ ] **Repository visibility: still private — flipping public is the - deliberate final step.** While the repository is private, finish - all remaining code, history, and test work (the 0.3.0b1 release - candidate included) and re-run every verification below on the - exact release commit. Only after everything passes is the - repository made public. -- [ ] **Enable and verify private vulnerability reporting — immediately - AFTER the repository becomes public.** GitHub private vulnerability - reporting is a setting for public repositories, so it cannot be - switched on while the repository is private and no step may claim - otherwise. The moment the repository goes public, enable the - setting and verify that the "Report a vulnerability" button appears - under the repository's Security tab, so `SECURITY.md`'s reporting - path is actually usable from the first public minute. - ## Pre-publication verification (on the exact release commit) -- [ ] `python3 -m compileall -q src tests` passes. -- [ ] `python3 -m unittest discover -s tests` passes. -- [ ] `python3 tests/public_readiness_check.py` exits 0 on the exact tree +Every verification below ran on merge commit `efff5724` — locally on +this checkout and green in CI on public `main`; protected-main CI +re-runs the same gates on every pull request, this status PR included: + +- [x] `python3 -m compileall -q src tests` passes. +- [x] `python3 -m unittest discover -s tests` passes (all unit tests, + including the offline real-Claude evidence contracts — unit tests + never call the Claude API; the tested real-host combination stays + documented in the README as Linux with Claude Code 2.1.208). +- [x] `python3 tests/public_readiness_check.py` exits 0 on the exact tree being published. -- [ ] `python3 tests/full_history_readiness_check.py` exits 0 on the +- [x] `python3 tests/full_history_readiness_check.py` exits 0 on the exact history being published (every tracked blob of every commit reachable from the release commit). -- [ ] `python3 tests/offline_install_smoke.py` passes. -- [ ] `python3 -m build` produces the `0.3.0b1` wheel and sdist; the +- [x] `python3 tests/offline_install_smoke.py` passes. +- [x] `python3 -m build` produces the `0.3.0b1` wheel and sdist; the wheel installs into a fresh environment and answers - `fable-session --version`. -- [ ] `git diff --check "$(git hash-object -t tree /dev/null)" HEAD` + `fable-session --version` (verified in CI on public `main`). +- [x] `git diff --check "$(git hash-object -t tree /dev/null)" HEAD` reports no whitespace errors. -- [ ] The prerelease tag name is `v0.3.0-beta.1` (distribution version - `0.3.0b1`); `release.yml` enforces this fail-closed via + +## Remaining operational action — the prerelease tag + +- [ ] **Push the `v0.3.0-beta.1` prerelease tag (distribution version + `0.3.0b1`) — the sole remaining operational action.** Only after + this status PR merges and protected-main CI passes on the merge + commit. `release.yml` enforces the canonical tag fail-closed via `tests/release_tag_check.py` (only the canonical tag for the - checked-out package version may release), re-runs these - gates, builds the artifacts, generates `SHA256SUMS`, attests build - provenance, and creates a GitHub prerelease. Package-index - publication stays out of scope for this prerelease. + checked-out package version may release), re-runs these gates, + builds the artifacts, generates `SHA256SUMS`, attests build + provenance, and creates the GitHub prerelease. Until that workflow + succeeds, no GitHub prerelease, release artifacts, or attestation + exist. Package-index publication stays out of scope for this + prerelease. diff --git a/README.md b/README.md index 5e180ce..5b2661d 100644 --- a/README.md +++ b/README.md @@ -628,7 +628,7 @@ python3 tests/full_history_readiness_check.py ## Contributing -Small, focused contributions are welcome once the repository is public: +Small, focused contributions are welcome — the repository is public: open an issue or PR at the official repository. Keep changes test-first (`python3 -m unittest discover -s tests`), offline, stdlib-only, and within the documented guarantees — anything that weakens the no-fallback, @@ -640,12 +640,15 @@ never a public issue. fable-session is licensed under the **MIT License** — see [LICENSE](LICENSE); package metadata says the same. The official home for this project is -`https://github.com/getaskclaw/fable-session` (private while the release -checklist is completed; it holds the independently reviewed clean root -commit). Public release remains blocked until the remaining items in -`PUBLIC_RELEASE_CHECKLIST.md` are complete; the ORIGINAL pre-export -working history must never be pushed publicly. Security reports: see -`SECURITY.md`. +`https://github.com/getaskclaw/fable-session` — the repository is public, +holds the independently reviewed clean history, and has GitHub private +vulnerability reporting enabled and verified. The source-level +public-beta gates in `PUBLIC_RELEASE_CHECKLIST.md` are complete; the +`v0.3.0-beta.1` GitHub prerelease does not exist yet — its tag is pushed +only after the release-status PR merges and protected-main CI passes, +and only a successful run of the tag workflow creates the prerelease. +The ORIGINAL pre-export working history must never be pushed publicly. +Security reports: see `SECURITY.md`. ## Out of scope diff --git a/tests/public_readiness_check.py b/tests/public_readiness_check.py index 59cb620..8fd2a4e 100644 --- a/tests/public_readiness_check.py +++ b/tests/public_readiness_check.py @@ -32,17 +32,19 @@ it) must never come back in ANY tracked file; - verifies README/PUBLIC_RELEASE_CHECKLIST.md/SECURITY.md identify MIT, the official repository URL, and GitHub private vulnerability reporting - truthfully. The official PRIVATE repository exists and holds exactly one - independently reviewed clean root commit, so stale wording claiming it - is uncreated fails, transient wording pinning a momentary state - (empty / commitless / zero-ref claims) fails, SECURITY must not - condition its reporting channel on a future publication, and the - checklist must keep: its BLOCKED status, the dirty-history warning (the - ORIGINAL pre-export working history must never be pushed publicly), the - checked record of the reviewed clean root commit, the named - full-history scan gate, and unchecked visibility and - vulnerability-reporting blockers (reporting is a public-repo setting, - enabled and verified immediately AFTER the flip); + truthfully. The official repository is PUBLIC, holds exactly the + independently reviewed clean history, and has private vulnerability + reporting enabled and verified, so stale wording claiming it is + uncreated or private fails, a stale BLOCKED release status fails, + transient wording pinning a momentary state (empty / commitless / + zero-ref claims) fails, SECURITY must not condition its reporting + channel on a future publication, and the checklist must keep: its + source-is-public status, the dirty-history warning (the ORIGINAL + pre-export working history must never be pushed publicly), the checked + records of the reviewed clean root commit, the visibility flip, the + reporting enablement, and branch protection, the named full-history + scan gate, and exactly one unchecked item — the pending v0.3.0-beta.1 + prerelease tag, the sole remaining operational action; - verifies the README notices and the guardrail files exist. Exit codes: 0 ready (current tree only — git HISTORY is explicitly not @@ -83,7 +85,7 @@ STALE_LICENSE_NEEDLE = "propri" + "etary" STALE_BLOCKER_WORD = "undec" + "ided" -# The repository at OFFICIAL_REPO_URL exists (private); release docs +# The repository at OFFICIAL_REPO_URL exists (public); release docs # claiming it is uncreated — or conditioning SECURITY's reporting channel # on a future publication — are stale. Composed so no tracked file # contains the phrases literally; docs wrap lines, so these needles are @@ -95,6 +97,18 @@ ) STALE_FUTURE_PUBLICATION_NEEDLE = "once the repository is " + "published" +# The repository is PUBLIC since the visibility flip (recorded, with the +# reporting and branch-protection gates, in PUBLIC_RELEASE_CHECKLIST.md). +# Release docs regressing to the pre-flip private state are stale. +# Composed so no tracked file contains the claims literally; matched +# whitespace-normalized. +STALE_PRIVATE_NEEDLES = ( + "official private " + "repository", + "repository is " + "private", + "while the repository is " + "private", + "still " + "private", +) + # Transient truths must never be pinned in release docs: the moment the # reviewed clean-history root is pushed, any claim that the repository is # empty, has no commits, or has zero refs becomes false. Composed so no @@ -276,7 +290,13 @@ def _check_repo_claims(name: str, text: str, findings: list[str]) -> None: if needle in flattened: findings.append( f"{name}: stale claim that the official repository is " - f"uncreated ({needle!r}) — it exists (private)" + f"uncreated ({needle!r}) — it exists (public)" + ) + for needle in STALE_PRIVATE_NEEDLES: + if needle in flattened: + findings.append( + f"{name}: stale claim that the official repository is " + f"private ({needle!r}) — it is public" ) for needle in TRANSIENT_EMPTY_NEEDLES: if needle in flattened: @@ -289,9 +309,11 @@ def _check_repo_claims(name: str, text: str, findings: list[str]) -> None: def check_docs(findings: list[str]) -> None: """README/checklist/SECURITY must identify MIT and the official URL - truthfully, keep the dirty-history warning, the BLOCKED status, and the - unchecked export/review/visibility blockers, and never reintroduce the - resolved license blocker or the stale repository-is-uncreated claim.""" + truthfully, keep the dirty-history warning and the source-is-public + status, record the resolved visibility/reporting/branch-protection + gates, keep the pending v0.3.0-beta.1 prerelease tag as the only + unchecked item, and never reintroduce the resolved license blocker, + a BLOCKED status, or a stale repository-is-uncreated/-private claim.""" readme = REPO / "README.md" if readme.is_file(): text = readme.read_text(encoding="utf-8") @@ -304,6 +326,21 @@ def check_docs(findings: list[str]) -> None: findings.append( "README.md: reintroduces the resolved license blocker wording" ) + if "the repository is public" not in _flat(text): + findings.append( + "README.md: must state that the official repository is " + "public" + ) + if "private vulnerability reporting enabled" not in _flat(text): + findings.append( + "README.md: must state that GitHub private vulnerability " + "reporting is enabled on the official repository" + ) + if "blocked" in text.lower(): + findings.append( + "README.md: stale claim that public release is blocked — " + "the source-level public-beta gates are complete" + ) _check_repo_claims("README.md", text, findings) checklist = REPO / "PUBLIC_RELEASE_CHECKLIST.md" @@ -331,10 +368,16 @@ def check_docs(findings: list[str]) -> None: "PUBLIC_RELEASE_CHECKLIST.md: the dirty-history warning " "(existing history must never be pushed publicly) is gone" ) - if "blocked" not in lowered: + if "blocked" in lowered: findings.append( - "PUBLIC_RELEASE_CHECKLIST.md: the BLOCKED release status " - "is gone" + "PUBLIC_RELEASE_CHECKLIST.md: stale BLOCKED release status " + "— the source is public; only the v0.3.0-beta.1 prerelease " + "tag remains" + ) + if "the source is public" not in _flat(text): + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: the canonical post-flip " + "status ('the source is PUBLIC') is missing" ) _check_repo_claims("PUBLIC_RELEASE_CHECKLIST.md", text, findings) if "full_history_readiness_check" not in text: @@ -348,30 +391,41 @@ def check_docs(findings: list[str]) -> None: if item.startswith("- [ ]")] checked = [_flat(item) for item in items if item.startswith("- [x]")] - for phrase in ("visibility", "vulnerability reporting"): - if not any(phrase in item for item in unchecked): + # The visibility flip, the post-flip reporting enablement, and + # branch protection are RESOLVED: each must be recorded as a + # checked item, and none may regress to an unchecked blocker. + for phrase in ("visibility", "vulnerability reporting", + "branch protection"): + if not any(phrase in item for item in checked): + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: no checked item records " + f"the resolved {phrase!r} gate — the flip and its " + "post-flip settings are complete and must stay recorded" + ) + for phrase in ("visibility", "vulnerability reporting", "export"): + if any(phrase in item for item in unchecked): findings.append( - "PUBLIC_RELEASE_CHECKLIST.md: no unchecked blocker " - f"mentions {phrase!r} — the visibility flip and the " - "post-flip private-vulnerability-reporting enable/verify " - "must stay explicitly incomplete" + "PUBLIC_RELEASE_CHECKLIST.md: stale unchecked " + f"{phrase!r} blocker — that gate is resolved; a " + "regression to the pre-flip state is untruthful" ) - # The clean-history export/review blocker is RESOLVED: the official - # private repository holds exactly one reviewed clean root commit. - # An unchecked export blocker would be stale; a missing checked - # record of the resolution would be untruthful. - if any("export" in item for item in unchecked): + # Exactly one operational action remains: pushing the canonical + # prerelease tag after this status PR merges and protected-main CI + # passes. (The len check short-circuits before unchecked[0].) + if not (len(unchecked) == 1 + and "v0.3.0-beta.1" in unchecked[0] + and "prerelease tag" in unchecked[0]): findings.append( - "PUBLIC_RELEASE_CHECKLIST.md: stale unchecked export " - "blocker — the clean-history export is resolved (one " - "reviewed clean root commit)" + "PUBLIC_RELEASE_CHECKLIST.md: exactly one unchecked item " + "must remain — the pending v0.3.0-beta.1 prerelease tag " + "(the sole remaining operational action)" ) - if not any("official private repository" in item + if not any("official repository" in item and "reviewed clean root commit" in item for item in checked): findings.append( "PUBLIC_RELEASE_CHECKLIST.md: no checked item records that " - "the official private repository holds the one reviewed " + "the official repository holds the one reviewed " "clean root commit" ) diff --git a/tests/test_mit_release.py b/tests/test_mit_release.py index 79c736d..7f3765d 100644 --- a/tests/test_mit_release.py +++ b/tests/test_mit_release.py @@ -10,13 +10,13 @@ URLs at https://github.com/getaskclaw/fable-session; - README, PUBLIC_RELEASE_CHECKLIST.md, and SECURITY.md truthfully identify MIT, the official repository URL, and GitHub private vulnerability - reporting — the official PRIVATE repository exists and is reserved for - the independently reviewed clean-history root (nothing pushed), so the - docs must neither claim it is still uncreated, nor claim it is - public/published, nor pin its transient momentary state (empty / - commitless / zero-ref claims would be falsified by the approved - clean-root push), and SECURITY must not claim the private-reporting - channel was verified enabled; + reporting — the official repository is PUBLIC, holds exactly the + independently reviewed clean history, and has private vulnerability + reporting enabled and verified (recorded in the checklist), so the docs + must neither claim it is still uncreated or private, nor claim the + `v0.3.0-beta.1` GitHub prerelease exists before its tag workflow + succeeds, nor pin a transient momentary state (empty / commitless / + zero-ref claims); - the readiness checker (tests/public_readiness_check.py) fails the tree if any of the above regresses: LICENSE absent or non-MIT, metadata reverted to the old placeholder, official URLs drifting, or the docs @@ -59,11 +59,11 @@ STALE_PLACEHOLDER = "Propri" + "etary" STALE_BLOCKER_WORD = "undec" + "ided" -# The official repository now exists (private; nothing pushed). Stale -# wording claiming it is uncreated — and SECURITY conditioning its -# reporting channel on a future publication — is composed from fragments so -# no tracked file ever contains it literally. Docs wrap lines, so needle -# scans run on whitespace-normalized text (see flat()). +# The official repository now exists (public). Stale wording claiming it +# is uncreated — and SECURITY conditioning its reporting channel on a +# future publication — is composed from fragments so no tracked file ever +# contains it literally. Docs wrap lines, so needle scans run on +# whitespace-normalized text (see flat()). STALE_UNCREATED_NEEDLES = ( "not been " + "created", "not yet " + "created", @@ -71,6 +71,17 @@ ) STALE_FUTURE_PUBLICATION = "once the repository is " + "published" +# The repository is PUBLIC since the visibility flip; release docs +# regressing to the pre-flip private state are stale. Composed so no +# tracked file contains the claims literally; matched +# whitespace-normalized. +STALE_PRIVATE_NEEDLES = ( + "official private " + "repository", + "repository is " + "private", + "while the repository is " + "private", + "still " + "private", +) + # Transient truths must never be pinned in release docs: the moment the # reviewed clean-history root is pushed, any claim that the repository is # empty, has no commits, or has zero refs becomes false. Composed from @@ -218,7 +229,7 @@ def test_readme_states_mit_and_official_url_without_stale_wording(self): self.assertNotIn(STALE_PLACEHOLDER.lower(), lowered) self.assertNotIn(STALE_BLOCKER_WORD.lower(), lowered) - def test_checklist_license_blocker_is_resolved_but_release_stays_blocked(self): + def test_checklist_license_blocker_is_resolved_and_status_is_public(self): text = (REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text(encoding="utf-8") for line in text.splitlines(): stripped = line.strip() @@ -228,9 +239,10 @@ def test_checklist_license_blocker_is_resolved_but_release_stays_blocked(self): self.assertNotIn(STALE_BLOCKER_WORD.lower(), lowered) self.assertIn("mit", lowered) self.assertIn(OFFICIAL_URL, text) - # Release itself stays blocked: history is dirty until a reviewed - # clean-history export exists, and no remote may be created before. - self.assertIn("blocked", lowered) + # Post-flip: the source is public, the stale BLOCKED status must + # be gone, and the permanent dirty-history warning must stay. + self.assertNotIn("blocked", lowered) + self.assertIn("the source is public", flat(text)) self.assertIn("never be pushed", lowered) def test_security_md_names_the_official_repo_and_private_reporting(self): @@ -242,18 +254,41 @@ def test_security_md_names_the_official_repo_and_private_reporting(self): "SECURITY.md must not invent an email/contact address", ) - def test_docs_do_not_claim_the_repo_is_public_or_published(self): - # The repository exists but is PRIVATE; nothing has been pushed. - # Docs must not claim any form of public availability. + def test_docs_say_public_but_never_claim_the_prerelease_exists(self): + # The repository IS public now; README and the checklist must say + # so. The v0.3.0-beta.1 GitHub prerelease/tag does NOT exist yet, + # so no doc may claim any release artifact is live before the tag + # workflow succeeds. + readme = flat((REPO / "README.md").read_text(encoding="utf-8")) + self.assertIn("the repository is public", readme) + checklist = flat( + (REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text(encoding="utf-8")) + self.assertIn("the source is public", checklist) + # ("once the ... prerelease exists" stays legitimate conditional + # wording; only present-tense availability claims are banned.) + for name in RELEASE_DOCS: + lowered = flat((REPO / name).read_text(encoding="utf-8")) + for claim in ("prerelease is live", "prerelease is available", + "the prerelease exists", "has been published", + "now live"): + self.assertNotIn( + claim, lowered, + f"{name} must not claim the prerelease is live") + + def test_docs_do_not_claim_the_repo_is_private(self): + # Post-flip: any claim that the official repository is (still) + # private is stale. for name in RELEASE_DOCS: lowered = flat((REPO / name).read_text(encoding="utf-8")) - for claim in ("is now public", "already public", "now live", - "has been published"): - self.assertNotIn(claim, lowered, - f"{name} must not claim the repo is live") + for needle in STALE_PRIVATE_NEEDLES: + self.assertNotIn( + needle, lowered, + f"{name} still claims the official repository is " + "private", + ) def test_docs_do_not_claim_the_repo_is_uncreated(self): - # The official repository was created (private); stale wording + # The official repository was created (now public); stale wording # claiming otherwise must be gone from every release doc. for name in RELEASE_DOCS: lowered = flat((REPO / name).read_text(encoding="utf-8")) @@ -281,10 +316,11 @@ def test_docs_never_pin_the_transient_empty_repo_state(self): def test_security_reporting_is_github_native_and_not_claimed_enabled(self): text = (REPO / "SECURITY.md").read_text(encoding="utf-8") lowered = flat(text) - # The repository exists, so reporting must not be conditioned on a - # future publication — but private vulnerability reporting was NOT - # verified enabled, so SECURITY must not claim that it is, and must - # say what to do if the GitHub-native button is absent. + # Reporting is enabled and verified — that operational fact is + # recorded in PUBLIC_RELEASE_CHECKLIST.md. SECURITY.md itself + # stays purely descriptive (the Security-tab button plus what to + # do if it is ever absent), so its wording can never go stale, and + # it still must not be conditioned on a future publication. self.assertNotIn(STALE_FUTURE_PUBLICATION, lowered) self.assertNotIn("reporting is enabled", lowered) self.assertIn("report a vulnerability", lowered) @@ -292,17 +328,18 @@ def test_security_reporting_is_github_native_and_not_claimed_enabled(self): self.assertIn("public issue", lowered) def test_checklist_records_the_reviewed_clean_root_durably(self): - # 0.3.0b1 truth: the official private repository now holds exactly - # one reviewed clean root commit — the clean-history export blocker - # is resolved. Durable wording only (never "empty"/"commitless"). + # Post-flip truth: the official (now public) repository holds + # exactly one reviewed clean root commit with only reviewed work + # on top — the clean-history export blocker is resolved. Durable + # wording only (never "empty"/"commitless"). checked = [flat(item) for item in checklist_items() if item.startswith("- [x]")] self.assertTrue( - any("official private repository" in item + any("official repository" in item and "reviewed clean root commit" in item for item in checked), "checklist must record, in durable wording, that the official " - "private repository holds the one reviewed clean root commit; " + "repository holds the one reviewed clean root commit; " f"checked items: {checked}", ) self.assertTrue( @@ -311,32 +348,51 @@ def test_checklist_records_the_reviewed_clean_root_durably(self): f"recorded as such; checked items: {checked}", ) - def test_checklist_keeps_visibility_and_reporting_unchecked(self): - unchecked = [flat(item) for item in checklist_items() + def test_checklist_resolves_visibility_reporting_and_protection(self): + items = checklist_items() + unchecked = [flat(item) for item in items if item.startswith("- [ ]")] - for phrase in ("visibility", "vulnerability reporting"): + checked = [flat(item) for item in items + if item.startswith("- [x]")] + for phrase in ("visibility", "vulnerability reporting", + "branch protection"): self.assertTrue( - any(phrase in item for item in unchecked), - f"an unchecked blocker must still cover {phrase!r}; " - f"unchecked items: {unchecked}", + any(phrase in item for item in checked), + f"a checked item must record the resolved {phrase!r} " + f"gate; checked items: {checked}", ) - # The export/review blocker is resolved: no unchecked item may - # still claim the clean-history export is incomplete. + self.assertFalse( + [item for item in unchecked if phrase in item], + f"{phrase!r} is resolved post-flip and must not regress " + f"to an unchecked blocker; unchecked items: {unchecked}", + ) + # The export/review blocker stays resolved too. self.assertFalse( [item for item in unchecked if "export" in item], f"the export blocker is resolved; unchecked items: {unchecked}", ) - def test_checklist_orders_private_to_public_correctly(self): + def test_checklist_sole_unchecked_item_is_the_prerelease_tag(self): + unchecked = [flat(item) for item in checklist_items() + if item.startswith("- [ ]")] + self.assertEqual( + len(unchecked), 1, + "exactly one operational action may remain (the v0.3.0-beta.1 " + f"prerelease tag); unchecked items: {unchecked}", + ) + self.assertIn("v0.3.0-beta.1", unchecked[0]) + self.assertIn("prerelease tag", unchecked[0]) + + def test_checklist_records_reporting_enabled_after_the_flip(self): # Private vulnerability reporting is a PUBLIC-repository setting: - # the checklist must sequence it AFTER the visibility flip and - # never claim it can be enabled while private. + # the checklist must record that it was enabled immediately AFTER + # the visibility flip and verified — never that it was switched on + # while the repository was private. text = flat((REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text("utf-8")) self.assertNotIn("while private, enable private vulnerability", text) - self.assertIn("after", text) - self.assertIn("public", text) - self.assertIn("immediately", text) - self.assertIn("verify", text) + self.assertIn("immediately after", text) + self.assertIn("public-repository setting", text) + self.assertIn("verified", text) def test_checklist_keeps_a_full_history_scan_gate(self): text = (REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text("utf-8") @@ -511,17 +567,24 @@ def test_security_conditioning_on_a_future_publication_fails(self): self.doCleanups() self.assert_finding(findings, "SECURITY.md") - def test_checklist_losing_the_blocked_status_fails(self): - self.mutate("PUBLIC_RELEASE_CHECKLIST.md", - lambda raw: raw.replace(b"BLOCKED", b"READY")) + def test_checklist_regressing_to_a_blocked_status_fails(self): + self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw + + b"\n**Status: public release is " + b"BLOCKED.**\n") findings = self.scan() self.doCleanups() self.assert_finding(findings, "blocked") - def test_checklist_checking_off_the_visibility_blocker_fails(self): + def test_readme_regressing_to_a_blocked_release_claim_fails(self): + self.mutate("README.md", lambda raw: raw + + b"\nPublic release remains " + b"blocked.\n") + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, "README.md") + + def test_checklist_unchecking_the_visibility_record_fails(self): self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( - b"- [ ] **Repository visibility", - b"- [x] **Repository visibility")) + b"- [x] **Repository visibility", + b"- [ ] **Repository visibility")) findings = self.scan() self.doCleanups() self.assert_finding(findings, "visibility") @@ -533,14 +596,32 @@ def test_checklist_losing_the_full_history_gate_fails(self): self.doCleanups() self.assert_finding(findings, "full-history") - def test_checklist_checking_off_the_reporting_blocker_fails(self): + def test_checklist_unchecking_the_reporting_record_fails(self): self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( - b"- [ ] **Enable and verify private vulnerability reporting", - b"- [x] **Enable and verify private vulnerability reporting")) + b"- [x] **Private vulnerability reporting", + b"- [ ] **Private vulnerability reporting")) findings = self.scan() self.doCleanups() self.assert_finding(findings, "vulnerability reporting") + def test_checklist_checking_off_the_pending_tag_fails(self): + self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( + b"- [ ] **Push the `v0.3.0-beta.1` prerelease tag", + b"- [x] **Push the `v0.3.0-beta.1` prerelease tag")) + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, "v0.3.0-beta.1") + + def test_docs_claiming_the_repo_is_private_fails(self): + stale = ("\nNote: the official repository " + + "is private.\n").encode() + for name in RELEASE_DOCS: + with self.subTest(doc=name): + self.mutate(name, lambda raw, s=stale: raw + s) + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, name) + def test_security_md_losing_the_official_url_fails(self): self.mutate("SECURITY.md", lambda raw: raw.replace(b"getaskclaw", b"example")) diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index dfff60d..02891e5 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -10,7 +10,9 @@ Plus the guardrail files of the migration release: the rewritten README notices, SECURITY.md without an invented contact, the public-release -checklist that keeps release blocked, and a parseable CI workflow. +checklist that truthfully records the post-flip public-beta status (the +source is public; only the v0.3.0-beta.1 prerelease tag is pending), and +a parseable CI workflow. History is out of scope here by design: the readiness check covers the current tree only. Old history still contains the removed internal @@ -384,21 +386,26 @@ def test_security_md_uses_github_private_reporting_and_no_contact(self): class TestPublicReleaseChecklist(unittest.TestCase): - def test_checklist_blocks_release_on_the_known_blockers(self): + def test_checklist_records_the_post_flip_public_beta_status(self): path = REPO / "PUBLIC_RELEASE_CHECKLIST.md" self.assertTrue(path.is_file(), "PUBLIC_RELEASE_CHECKLIST.md is missing") text = path.read_text(encoding="utf-8").lower() for phrase in ( - "license", # resolved MIT — the entry must stay documented - "history", # old history holds removed internal references - "export", # clean-history export/review not complete - "owner", # owner/URLs resolved — must stay documented - "remote", # this checkout configures no remote to it - "visibility", # repo must stay private until release - "private", # the official PRIVATE repository exists + "license", # resolved MIT — must stay documented + "history", # the old-history warning is permanent + "export", # clean-history export resolved + recorded + "owner", # owner/URLs resolved — must stay documented + "visibility", # resolved: the repository is public + "vulnerability reporting", # resolved: enabled and verified + "branch protection", # resolved: protected main + "v0.3.0-beta.1", # the sole remaining operational action ): self.assertIn(phrase, text, f"checklist must cover: {phrase}") - self.assertIn("blocked", text) + self.assertNotIn( + "blocked", text, + "stale BLOCKED status — the source is public and only the " + "prerelease tag remains", + ) # Verified action pins (tag SHAs), with their friendly versions. These are From a89fef98979221290b9542840f872f9f204b442f Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 13:37:13 +0000 Subject: [PATCH 2/5] Make README a beginner-first landing page; move deep detail to docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md (181 lines): one-sentence plain-words explanation (local Python wrapper for the Claude Code CLI — not a model, not an Anthropic product), the user-supplied 'Session paused' screenshot preserved byte-for-byte at docs/assets/fable-5-session-paused.jpg with its visible message (numbered options included) transcribed as indexable plain text, explicit what-it-does / what-it-never-does framing, prerequisites, the unchanged tag-pinned install path and pre-PyPI warning, a first dry run, the three-command lifecycle, docs links, and the truthful status/MIT section. - docs/REFERENCE.md: the deep lifecycle/config/state/privacy/audit/ watch/migration/development/release material moved verbatim from the README with relative links corrected; no contract weakened. - docs/QUICKSTART.md: one safe end-to-end beginner example — checkout, registry (Fable 5 high, fallback stop, no budget cap), repo profile, task file, dry run, launch, one watch, one audit — placeholders only, no raw Claude launch, no bypass flags. - tests: readiness checker and unit tests now require the beginner explanation, screenshot path + exact preserved bytes, indexable transcription, no-bypass wording, Quickstart/Reference links and resolvable relative links, and a loose landing-page length band; the deep payload/budget/usage/migration assertions follow their prose to docs/REFERENCE.md. All existing public/security/history claims stay enforced. Co-Authored-By: Claude Fable 5 --- README.md | 688 ++++--------------------- docs/QUICKSTART.md | 162 ++++++ docs/REFERENCE.md | 575 +++++++++++++++++++++ docs/assets/fable-5-session-paused.jpg | Bin 0 -> 26049 bytes tests/public_readiness_check.py | 48 +- tests/test_public_readiness.py | 291 ++++++++++- 6 files changed, 1152 insertions(+), 612 deletions(-) create mode 100644 docs/QUICKSTART.md create mode 100644 docs/REFERENCE.md create mode 100644 docs/assets/fable-5-session-paused.jpg diff --git a/README.md b/README.md index 5b2661d..acba5b5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # fable-session +fable-session is a local Python wrapper for the Claude Code CLI that +gives each coding session a small, bounded context before it launches, +so routine work is less likely to trip an accidental pause — it is not a +model, and it is not an Anthropic product. + > **Unofficial; not affiliated with Anthropic.** This is an independent > community tool for operating the Claude Code CLI. It is not made, > endorsed, or supported by Anthropic. @@ -9,64 +14,70 @@ > never makes unsafe work permissible. If a session is paused or refused, > fable-session reports that truthfully and stops. -## The problem +## Have you seen this? + +![Claude Code terminal showing a Session paused message: Fable 5's safeguards flagged this message, with options to switch to Opus 4.8 or edit the prompt and retry with Fable 5](docs/assets/fable-5-session-paused.jpg) + +The screenshot above reads, word for word: + +Session paused -Broad safety safeguards sometimes pause *safe, routine* coding sessions: -a large or noisy prompt — a long log excerpt, a legacy code dump, an -unrelated word that happens to be a trigger term — can trip a pause even -though the actual task is ordinary engineering work. The most common cause -is oversized, unbounded context. +Fable 5's safeguards flagged this message. The safeguards are +intentionally broad right now and may flag safe and routine coding, +cybersecurity, or biology work. These measures let us bring you +Mythos-level capabilities sooner, and we're working to refine them. +Send feedback with /feedback or learn more -fable-session reduces those false positives the honest way, by controlling -what goes *into* a session and verifying what came *out* of it: +1. Switch to Opus 4.8 +2. Edit prompt and retry with Fable 5 + +If you keep hitting this pause during ordinary work with Fable 5 in +Claude Code, the most common *avoidable* cause is oversized, unbounded +context: a huge prompt, long pasted logs, or a stray trigger word deep in +a legacy code dump. fable-session exists to reduce those accidental +pauses — honestly, by shaping what goes *into* a session before launch, +never by touching a session after it is flagged. + +## What it does - **bounded context** — each session gets a small, structured brief built from a task file and a repo-owned profile, with a hard byte budget, instead of an unbounded pile of ambient context; -- **pinned model, no automatic fallback** — the requested model either - serves the session or the session stops (`switchModelsOnFlag: false` is - always attached); nothing silently degrades or retries; -- **evidence, not vibes** — a post-run audit proves from the session's own - structured JSONL stream which models actually served it, and a watchdog - monitors exactly one session's lifecycle (including structured - refusal/pause events) without ever acting on it. - -A genuinely flagged session stays flagged: the pause/refusal is surfaced -as a structured event and the run ends. The scope of this tool is to stop -*accidental* pauses caused by oversized context, and to make every run's -outcome provable after the fact. - -## Commands - -One console command, three subcommands: - -- `fable-session run` — resolve a registered project, build a bounded - brief, construct the exact Claude CLI command, and (only on explicit - request) launch it in a **new** tmux session. -- `fable-session audit` — post-run model-purity audit: stream one completed - session's JSONL transcript and report which models actually served it. -- `fable-session watch` — a JSONL-first monitor for exactly ONE - runner-created lane (one manifest). It stays silent when nothing changes, - emits one compact JSON object per newly confirmed structured lifecycle - event, and in `--follow` mode exits by itself at the lane's terminal - state. It never scans hosts, projects, panes, or PIDs, and never acts on +- **pinned model, no automatic fallback** — the model you request either + serves the session or the session stops; nothing silently degrades, + switches, or retries; +- **evidence afterwards** — a post-run model audit reads the session's + own structured JSONL stream and proves which models actually served it, + and a watchdog reports one session's lifecycle without ever acting on the session. -Runtime dependencies: Python 3.12 standard library only. +## What it does not do -## Install +When a session is genuinely paused or refused, fable-session: -Prerequisites: +- does not continue a paused run; +- does not edit or retry a flagged prompt; +- does not switch models (options 1 and 2 in the screenshot are choices + for you, the human — this tool never takes either of them for you); +- does not weaken or bypass safeguards in any way. -- **Python 3.12+** (stdlib only — the package has zero runtime - dependencies); -- **tmux** (launched sessions run inside a new tmux session); -- **Claude Code CLI** installed and authenticated separately (this tool - never ships, wraps, or configures it). +A real pause is recorded and the run stops: the pause is surfaced as a +structured event, and what happens next is your decision. The scope of +this tool is stopping *accidental* pauses caused by oversized context, +and making every run's outcome provable after the fact. -The tested real-host combination is **Linux with Claude Code 2.1.208**; -other platforms and CLI versions are not verified and no broader claim is -made. +## Prerequisites + +- **Linux** — the tested real-host combination is Linux with + **Claude Code 2.1.208**; other platforms and CLI versions are not + verified and no broader claim is made; +- **Python 3.12+** — the package has zero runtime dependencies + (standard library only); +- **tmux** — launched sessions run inside a new tmux session; +- **Claude Code CLI**, installed and authenticated separately — this + tool never ships, wraps, or configures it. + +## Install > **Pre-PyPI beta — read before installing.** fable-session 0.3.0b1 is > **not published on PyPI** and the name is not yet owned there, so do @@ -75,8 +86,8 @@ made. > package-index publication (a later, separate decision), install only > from the tag-pinned GitHub source below or a GitHub release artifact — > and these commands work **only after** the public `v0.3.0-beta.1` -> tag exists in the official repository. Before then, build from a checkout -> (see Development). +> tag exists in the official repository. Before then, install from a +> checkout as shown in the [Quickstart](docs/QUICKSTART.md). Install into an isolated environment from the tag-pinned source: @@ -96,545 +107,64 @@ Alternatively, once the `v0.3.0-beta.1` GitHub prerelease exists, download its attested wheel plus `SHA256SUMS`, verify the checksum, and install the wheel file directly. -### Dry run (default — starts nothing) - -```bash -fable-session run --project example-api --task /absolute/path/task.md --dry-run -``` - -`--dry-run` is the default; omitting all launch flags behaves identically. -The dry run: - -- resolves the project from `~/.config/fable-session/projects.toml` - (override with `--registry`); -- loads the repo-owned profile (`agent-context/profiles/*.toml`); -- builds the bounded brief from the packaged `bounded-worker.md` template - (importable package data — no dependency on the source checkout); -- writes a run manifest under - `~/.local/state/fable-session/runs//manifest.json` - (override with `--state-dir`); -- prints project, model/effort/fallback, repo, branch, task, brief mode, - session id, the future structured-stream path and native JSONL transcript - path (described only — a dry run creates no transcript), manifest path, - and the **redacted** launch command. +## Your first dry run (starts nothing) -Dry runs are deterministic: identical registry/profile/task inputs produce -the identical run id, session id, and manifest. - -### Explicit launch +A dry run is the default and launches nothing — it shows you exactly +what *would* run and writes a run manifest: ```bash -fable-session run --project example-api --task /absolute/path/task.md \ - --launch --tmux api-example +fable-session run --project example-api --task /absolute/path/task.md --dry-run ``` -- `--launch` is required to start anything; `--tmux` names the new session. -- The full session name is strictly validated (`[A-Za-z0-9_-]`, max 64 - chars — `:` and `.` are tmux target syntax and are rejected) and must - start with the project's `tmux_prefix` (same character set). -- The tool only ever creates a **new** tmux session (exact-name match); if - the name already exists it fails and starts nothing. It never uses - `--continue` or `--resume`. -- Model, effort, and permission mode are pinned from the registry; - `--fallback-model` and `--dangerously-skip-permissions` are never emitted - (config accepts only `fallback = "stop"` and an allowlisted - `permission_mode`; `bypassPermissions` is rejected at parse time). -- `fallback = "stop"` is enforced, not just documented: every command - carries `--settings '{"switchModelsOnFlag": false}'`, so the pinned model - either serves or the session stops — it never switches silently. -- Only the verified prompt flags `--append-system-prompt` (profile - `context_mode = "append"`) and `--system-prompt` (`"replace"`) are used; - no file-based prompt flags. Since 0.3.0b1 the prompt **payloads** never - ride the tmux command line: the runner writes the brief and task as - run-scoped mode-0600 payload files (`brief.payload`, `task.payload` - inside the run directory) from its single scanned read, and the capture - wrapper — after verifying each payload's sha256 against the hash - recorded in the manifest — substitutes the brief for a single - placeholder token in the Claude argv and feeds the task to Claude on - stdin (`-p` with no inline prompt). This also repairs the verified - launch defect where a 7,745-byte task plus an 8,072-byte brief failed - with tmux's `command too long`: the tmux command stays short no matter - how large the (bounded) payloads are. -- If configured, the registry's `max_budget_usd` is passed as exactly one - `--max-budget-usd` argument, shown in the launch summary, and recorded - in the manifest; with no budget configured, no budget flag is emitted - and the manifest records `max_budget_usd: null`. The Claude CLI's own - budget enforcement is the mechanism — fable-session never kills or - stops a session itself. -- **Terminal evidence is captured, not inferred.** Claude runs with - `--output-format stream-json --verbose` under a shell-free capture - wrapper (`capture.py`, executed by absolute path — no `bash -c`, no - pipeline, no redirection strings). The wrapper creates the run-scoped - stream file `/stream.jsonl` exclusively (mode 0600; an - existing/symlinked destination fails closed before Claude is invoked), - hands it to Claude as stdout so the structured stream — including the - top-level terminal `result` event — lands byte-for-byte, and propagates - Claude's exit status. The manifest's `expected_transcript_path` names - this stream (what `fable-session watch` and `fable-session audit` - consume); `native_transcript_path` separately preserves Claude's native - session JSONL, which never contains the terminal `result` and is for - diagnosis only. The stream is private transcript data: it may contain - prompt and result text and is never echoed to runner output or the - manifest. - -### Launch identity and run state - -- Each launch gets a high-entropy run id - (`--`) and an independent random - Claude session id; identical launches in the same second can never share a - run directory, manifest, or JSONL transcript identity. -- The run directory is reserved atomically (`mkdir` with `exist_ok=False`, - retried with fresh entropy on collision) and a `pending` manifest is - written **before** tmux starts. If reservation or the pending write fails, - nothing is launched. -- After `tmux new-session` succeeds the manifest is atomically updated to - `launched`; if tmux fails it records `failed`. If a post-launch update - fails **before** its publish rename, the `pending` record remains on the - reservation-bound run-directory inode and already identifies the actor, - model, session id, and tmux session. -- Manifest paths in output and manifests are **reservation-time display - paths**. All writes go through the run-directory FD held since - reservation, so they always land in the reserved inode — but a same-UID - process can rename that directory despite the open FD, so the display - path may later stop resolving to it (and may even name a planted - replacement). Do not treat the display path as the manifest's current - location without re-proving its identity. -- Post-publish durability failure (`StateCommitUncertain`): if the - run-directory fsync *after* a successful publish rename fails, the new - complete manifest was already atomically published to the bound - run-directory inode and the previous one is gone from it — only - durability across a crash or power loss is unconfirmed. Errors report - the bound `(dev, ino)` taken from the held FD (never re-read from the - path) and label the reservation-time path as display-only, so recovery - tooling is never pointed at a possibly planted replacement. -- Run ids are re-validated defensively in the state layer: path separators, - `.`/`..`, control characters, and symlinked run directories can never - escape the canonical `runs/` root. -- The state directory path is interpreted lexically and trusted only via - directory file descriptors: every component is created/opened - component-by-component with `O_DIRECTORY | O_NOFOLLOW` from the filesystem - root. A symlink anywhere in the state path — including the state directory - itself and its `runs` child — is rejected as a `StateError`; symlinked - state directories are unsupported by contract (point `--state-dir` at the - real path). No write ever re-traverses a path after trust is established. -- Manifest updates are atomic and writer-private: each writer stages its - payload in its own unpredictable `.manifest-.tmp` - (`O_CREAT | O_EXCL | O_NOFOLLOW`), fully written, fsynced, and closed - before an `os.replace` publish relative to the trusted run-dir FD. - Concurrent writers are last-writer-wins; a reader can never observe a - partial or mutable-after-publish `manifest.json`. A crashed writer may - leave a stale `.manifest-*.tmp`, which other writers never adopt or - delete. - -### Post-run model audit - -The runner pins the model and disables model switching, but its manifest -proves only *intent*. `fable-session audit` checks the *evidence*: it -streams one completed Claude Code JSONL transcript and reports observed -model purity. It is generic — any registered project — with no -project-specific logic. - -```bash -# Explicit source -fable-session audit \ - --transcript /absolute/path/session.jsonl \ - --requested-model claude-fable-5 - -# Runner manifest source (reads `model` and `expected_transcript_path`; -# never mutates the manifest) -fable-session audit --manifest /absolute/path/manifest.json +Before this works you register your project once and add a small profile +to your repository. The [Quickstart](docs/QUICKSTART.md) walks your +whole first session end to end: install, registry, profile, task file, +dry run, launch, one watch, one audit. -# Machine-readable -fable-session audit --manifest /absolute/path/manifest.json --format json -``` +## The three commands -The two input modes are mutually exclusive. Every input path must be an -absolute, existing regular file; symlinks are rejected, not followed. - -What it reports: - -- **observed message models** — non-synthetic `message.model` values from - assistant entries, deduplicated by unique `message.id` (streamed/chunked - entries count once), as unique-message-id counts per model; -- **final response model** — the model of the last unique assistant message - with non-empty text content (`—`/`null` when unprovable, which forfeits - PURE); -- **synthetic messages** — `` entries are counted separately and - never prove a serving model; -- **auxiliary models** — result `modelUsage` keys beyond the observed - message models. Helper-model use (e.g. a smaller model doing - summarization) is reported honestly but does **not** by itself make a run - MIXED; -- **fallback/refusal events** — structured system events such as subtype - `model_refusal_fallback`, with any serving model they identify; -- **result metadata** — safe fields only (subtype, `is_error`, stop reason); -- **usage (0.3.0b1)** — a privacy-safe aggregate of the single terminal - result's `modelUsage` across all models: `input_tokens`, - `output_tokens`, `cache_read_input_tokens`, - `cache_creation_input_tokens`, `web_search_requests`, and - `total_cost_usd` — bounded numbers only, never prompt, result, or tool - text. When `modelUsage` is missing, or there is no single trustworthy - terminal result, `usage` is an explicit `null` (text output says - `usage: unavailable`) — zeroes are never invented. A structurally - malformed `modelUsage` (wrong types, negative or non-finite numbers) - additionally sets the reason code `malformed_model_usage` and forfeits - PURE: corrupt optional usage never falsely proves model purity; -- **terminal-result completeness** — a completed transcript carries exactly - one top-level `type="result"` event with `subtype: "success"` and boolean - `is_error: false`. A missing result (interrupted stream), more than one - result (concatenated runs — none is silently chosen; `result` is `null`), - or an unsuccessful/ambiguous result is an evidence-integrity failure - (`missing_terminal_result`, `multiple_terminal_results`, - `unsuccessful_terminal_result`). Completion is never inferred from the - last assistant text, status events, or anything outside the structured - result; `stop_reason` is reported but not required; -- **reason codes** — bounded machine-safe diagnostics (e.g. - `malformed_json_line`, `conflicting_duplicate_message_id`, - `missing_terminal_result`). - -Verdicts, for requested model `R`. PURE and MIXED both require exactly one -successful terminal result; a missing, multiple, or unsuccessful result is -UNKNOWN: - -- `PURE` (exit 0) — parsing complete and valid, exactly one successful - terminal result, at least one non-synthetic message observed, every - observed message model and the proven final response model are exactly - `R`, and no fallback/refusal event occurred. -- `MIXED` (exit 1) — complete evidence (including exactly one successful - terminal result) proves an observed non-synthetic message model other - than `R`, or a fallback/refusal event identifies a different serving - model. A later return to `R` stays MIXED. -- `UNKNOWN` (exit 2) — purity cannot be proven: missing, malformed, partial, - or conflicting evidence (duplicate id with conflicting models, truncated - trailing JSON, model-bearing message without an id), a missing, multiple, - or unsuccessful terminal result, synthetic-only transcripts, no provable - final response model, or a fallback/refusal without enough serving-model - evidence to classify MIXED. Evidence-integrity failures always win over a - tempting PURE/MIXED conclusion — an interrupted or concatenated stream is - UNKNOWN even when all observed messages match `R` or a different model - would otherwise prove MIXED. - -Exit code 2 is also used for usage, file, and manifest errors. Neither -output format ever contains prompts, result free text, tool payloads, or -any other transcript content; the JSON object (stable keys, -`schema_version: 1`) carries no filesystem paths. - -### Exact-lane session watchdog - -`fable-session watch` monitors exactly one runner-created lane, identified -by the absolute path of its run manifest. It is read-only with respect to -the lane: it never presses keys, pastes text, retries, switches models, -creates prompts, kills sessions, or touches product files — it reads, -classifies, and reports. +One console command, three subcommands — the whole lifecycle: ```bash -# One scan, then return +fable-session run --project example-api --task /abs/task.md --launch --tmux api-example fable-session watch --manifest /abs/runs//manifest.json --once - -# Poll only this lane's transcript; exits by itself at the terminal state -fable-session watch --manifest /abs/runs//manifest.json \ - --follow --poll-interval 2 --settle-seconds 10 -``` - -Inputs fail closed (exit 2): the manifest and the transcript it names via -`expected_transcript_path` must each be an absolute, existing, regular, -non-symlinked file (the transcript is re-verified on every scan). The lane -identity is the manifest's `session_id` (required) plus `run_id` and -`tmux_session` (optional); a transcript carrying any other session id is a -lane mismatch and is refused. There is no global or multi-lane mode — no -scanning of other projects, tmux panes, PIDs, home directories, or hosts. - -**Events.** Evidence rules are shared with the model audit (one truth -model): only structured top-level JSONL entries are events; quoted warning -text inside ordinary assistant/user message content (a message that merely -*says* "Session paused") never is. Each newly confirmed event is one -compact JSON object on stdout (`schema_version: 1`, session/run identity, -bounded structural fields only); when there is nothing new, stdout is -empty. - -- `refusal_pause` — structured system event whose subtype names a refusal - or pause; -- `model_fallback` — structured system fallback event (a subtype naming - both, e.g. `model_refusal_fallback`, counts as the fallback), with any - serving model it identifies; -- `permission_block` — structured system permission-block event; -- `malformed_evidence` — lifecycle-relevant integrity failure (malformed - JSON line, non-object line, conflicting session-id fields) before any - terminal result; diagnostic, not terminal; -- `completed` — exactly one successful terminal result (`subtype: - "success"`, boolean `is_error: false`); **terminal**. When the result - carries a structurally valid `modelUsage`, the event includes the same - bounded `usage` aggregate as the audit (tokens, web-search requests, - `total_cost_usd`) — and makes no usage claim otherwise; -- `completed_error` — exactly one unsuccessful (or ambiguous-fields) - terminal result; **terminal**; carries the bounded `usage` aggregate - under the same rule; -- `completed_ambiguous` — more than one terminal result, or a terminal - result on malformed evidence (integrity failures win over a tempting - completion verdict, exactly as in the audit); **terminal**; -- `interrupted` — `--follow` only, see below; **terminal**. - -A missing terminal result is never called complete. An unterminated final -JSONL line is an in-progress write: tolerated until it completes, never -malformed evidence. - -**Silence and dedupe.** Dedupe state persists per lane (default -`watchdog.state.json` next to the manifest; override with an absolute -`--state-file`), keyed by session id plus event identity, so repeated scans -of an unchanged transcript emit nothing. Writes are atomic (exclusive -temp + fsync + rename) and contain only the schema version, lane identity, -and emitted event keys — never prompts, tool payloads, credentials, or -transcript content. A state file belonging to a different session fails -closed; separate sessions can never collide. The repo's documented -same-UID limitation applies to this state exactly as to run manifests. - -**Auto-exit and interruption.** `--follow` polls only this manifest's -transcript and exits after emitting/deduping the lane's terminal event — -it never stays resident after completion. An interruption is claimed only -when ALL of the following hold: the manifest names a tmux session, an -exact-name probe (`tmux has-session -t =NAME`, never a scan) says it is -gone, the transcript stayed byte-stable across consecutive polls for at -least the settle window (`--settle-seconds`, default 10.0), and no -terminal result exists. Then exactly one `interrupted` event is emitted -and the watchdog exits 1. A failed probe proves nothing and never supports -an interruption claim; with no `tmux_session` in the manifest, -interruption detection is disabled and only a terminal result ends the -follow. Start `--follow` after the transcript file exists — a missing -transcript is a rejected input, not a wait state. - -**Exit codes (stable).** - -- `0` — clean: `--once` scanned with the lane in progress or successfully - completed; `--follow` exited on the successful terminal result. -- `1` — the lane reached a confirmed non-success terminal state: - `completed_error`, `completed_ambiguous`, or `interrupted`. -- `2` — usage, input-validation, or state error (bad flags; missing, - relative, malformed, non-regular, or symlinked manifest / transcript / - state inputs; lane/session mismatch; state write failure). Nothing was - concluded about the lane. - -## Configuration - -Host registry (`~/.config/fable-session/projects.toml`, see -`examples/projects.toml`): - -```toml -[project.example-api] -repo = "/srv/example-project" -profile = "agent-context/profiles/fable-5.toml" -model = "claude-fable-5" -effort = "high" -fallback = "stop" -permission_mode = "auto" -tmux_prefix = "api-" -# Optional (0.3.0b1): enforceable per-session budget, passed to Claude as -# exactly one `--max-budget-usd 12.5`. Must be a finite positive TOML -# number with a plain-decimal form; booleans, zero, negatives, NaN, -# infinities, strings, and exponent-only representations are rejected -# before any command is built. Omit the key for no budget flag at all. -max_budget_usd = 12.5 +fable-session audit --manifest /abs/runs//manifest.json ``` -`permission_mode` is required and allowlisted (`acceptEdits`, `auto`, -`manual`, `dontAsk`, `plan` — the installed Claude CLI's choices minus -`bypassPermissions`); it is passed explicitly as `--permission-mode` so the -session never inherits an ambient permission policy. `bypassPermissions` and -dangerous-skip flags are rejected outright, as is the unsupported `default`. - -Repo-owned profile (`/agent-context/profiles/fable-5.toml`): - -```toml -version = 1 -product = "Internal data dashboard built with Vue and FastAPI." -context_mode = "append" -max_brief_bytes = 2048 -allowed_roots = ["backend", "frontend", "tests", "docs"] -``` - -Parsing is strict: unknown or secret-like keys, secret-shaped values, -relative repo paths, unsupported profile versions, and `allowed_roots` -escaping the repo all fail before any command is built. Project names are -strict identifiers (`[A-Za-z0-9][A-Za-z0-9_-]{0,31}`) because they flow -into run ids and state paths. The profile path is resolved and must stay -inside the repo even through symlinks. Project/model differences live -entirely in these TOML files — the runner has no project-specific behavior. - -Task and profile files are read exactly **once** per run: the same immutable -text is parsed, secret-scanned, hashed, rendered into the brief, and passed -to Claude, so a file changing on disk mid-run cannot smuggle unscanned -content into the launched command. - -## Task file format - -```markdown -# Task: short title - -## Goal -One short paragraph. - -## Checks -- one bullet per required check - -## Boundaries -- one bullet per boundary - -## Report -- one bullet per report element - -## Docs (optional) -- path/to/canonical-doc.md -``` - -Goal, Checks, Boundaries, and Report are required. Docs entries must be bare -paths — canonical docs are referenced, never copied into the brief. The -rendered brief must fit in the profile's `max_brief_bytes`. Secret-shaped -content anywhere in the task file or brief aborts the run. - -## Privacy and metadata - -Run manifests and user-facing output contain hashes, byte sizes, and paths — -never brief/prompt contents, credentials, or terminal history. The manifest -also records the effective permission mode and no-fallback settings, plus -the expected Claude Code JSONL transcript path -(`~/.claude/projects//.jsonl`; the session id is -passed to Claude via `--session-id`). - -Prompt payload handling (0.3.0b1): the tmux command line never carries -prompt text — the brief and task travel as run-scoped mode-0600 payload -files (`brief.payload`, `task.payload` in the run directory), written from -the single scanned read and verified by sha256 before delivery. The task -reaches Claude on **stdin** and appears on no process command line at all. -The **brief** is substituted into the Claude argv at exec time, so it -**remains visible to same-host process inspection** (e.g. `ps`, -`/proc//cmdline`) of the Claude process for its lifetime, and the -payload files themselves are readable by the same UID. Do not put anything -in a task file that other users of the same host must not see; -secret-shaped content is rejected before launch. A dry run writes only the -manifest — it leaves no payload material behind, and its manifest records -`payload_files_created: false` (payload paths are descriptive only). A -launch manifest carries `payload_files_created: true` only in records -written after **both** run-scoped payload files were actually created; -records written before that point (the `pending` record, or a `failed` -record after a payload-write failure) truthfully say `false`. Manifests -from earlier releases lack the key and stay fully readable. - -## Migrating from claude-context-tools 0.1.x - -Version 0.2.0 renamed the distribution, import package, CLI, manifests, and -default paths. Nothing migrates implicitly: fable-session never reads, -adopts, or mutates the old directories. - -| 0.1.x | 0.2.0 | -| --- | --- | -| distribution `claude-context-tools` | `fable-session` | -| import package `claude_context_tools` | `fable_session` | -| `claude-context-run` | `fable-session run` | -| `claude-context-audit-models` | `fable-session audit` | -| `claude-session-watchdog` | `fable-session watch` | -| `~/.config/claude-context/` | `~/.config/fable-session/` | -| `~/.local/state/claude-context/` | `~/.local/state/fable-session/` | -| manifest `tool: "claude-context-run "` | `tool: "fable-session "` | - -Explicit migration steps: - -1. Copy your registry yourself: - `cp ~/.config/claude-context/projects.toml ~/.config/fable-session/projects.toml` - (or pass `--registry` explicitly). The file format is unchanged. -2. Old run state stays where it is and stays readable: `fable-session - audit --manifest ` and `fable-session watch --manifest ` accept 0.1.x manifests when you point at them explicitly — - nothing keys off the manifest's `tool` string. New runs write only under - the new state directory (or your `--state-dir`). -3. Deterministic dry-run ids changed with the rename (the id namespace is - now `fable-session`), so a 0.2.0 dry run of identical inputs produces a - different run/session id than 0.1.x did. Launch ids were always random. - -Rollback: uninstall 0.2.0 and reinstall 0.1.x. Because 0.2.0 never touches -the old config/state directories, a rollback finds them exactly as 0.1.x -left them; delete `~/.config/fable-session/` and -`~/.local/state/fable-session/` if you want no trace of the trial. - -The three old console names (`claude-context-run`, -`claude-context-audit-models`, `claude-session-watchdog`) were kept for -exactly one migration release (0.2.x) as deprecated compatibility aliases -and are **removed in 0.3.0b1**: they are no longer installed and no longer -exist as entry points. Use `fable-session run|audit|watch`. Reading old -manifests is unaffected — `fable-session audit --manifest` and -`fable-session watch --manifest` still accept 0.1.x manifests when pointed -at them explicitly. There is deliberately no short alias — nothing named -`fs` is ever installed. - -## Development - -```bash -python3 -m compileall -q src tests -python3 -m unittest discover -s tests -v -``` - -Unit tests never call the Claude API or start real tmux sessions; subprocess -boundaries are mocked. `--registry`, `--state-dir`, and `--claude-bin` exist -so tests and fixtures never touch real host state. All test fixtures are -synthetic. - -The brief template ships inside the package -(`src/fable_session/templates/bounded-worker.md`, declared as setuptools -package data) and is loaded via `importlib.resources`, so an installed -distribution renders briefs without the source checkout. The offline -install smoke proves it end to end — disposable venv under `/tmp`, -`pip install --no-build-isolation --no-deps --no-index`, installed console -commands, and one sanitized dry-run from outside the checkout: - -```bash -python3 tests/offline_install_smoke.py -``` - -In restricted CI environments the smoke accepts an explicitly pre-seeded -wheelhouse for its offline build backend: point `FABLE_SESSION_WHEELHOUSE` -at a directory containing a `setuptools>=77` wheel downloaded in an -earlier, clearly network-allowed step; the install phase itself still runs -with `--no-index --no-build-isolation --no-deps`. - -The public-readiness check scans the tracked tree (naming, notices, no -internal identifiers, no credential-shaped literals), and its full-history -sibling applies the same policy to every tracked blob of every commit -reachable from HEAD; both run locally and in CI: - -```bash -python3 tests/public_readiness_check.py -python3 tests/full_history_readiness_check.py -``` - -## Release notes - -**0.3.0b1** (public beta 1; the matching GitHub prerelease tag is -`v0.3.0-beta.1`): - -- optional registry key `max_budget_usd`: an enforceable Claude budget - passed as exactly one `--max-budget-usd` argument, printed in the - summary and recorded as structural manifest metadata; -- command-length/privacy repair: prompt payloads moved off the tmux - command line into hash-verified run-scoped 0600 payload files (brief via - placeholder substitution, task via stdin), fixing the `command too long` - launch failure for large task/brief pairs; -- privacy-safe usage reporting: bounded `modelUsage` aggregates (tokens, - web-search requests, `total_cost_usd`) in `audit --format json`, audit - text output, and terminal `watch` events — explicit `null` instead of - invented zeroes, and malformed usage never falsely proves purity; -- the three deprecated 0.1 console aliases are removed after their one - migration release; -- public packaging metadata (SPDX MIT, README long description, honest - classifiers), pinned CI actions, a full-history readiness scan, and a - tag-triggered attested prerelease workflow. - -## Contributing - -Small, focused contributions are welcome — the repository is public: -open an issue or PR at the official repository. Keep changes test-first -(`python3 -m unittest discover -s tests`), offline, stdlib-only, and -within the documented guarantees — anything that weakens the no-fallback, -permission, secret-scanning, or evidence-integrity contracts (or tries to -bypass safeguards) will not be accepted. Security reports: `SECURITY.md`, -never a public issue. +- `fable-session run` — resolve a registered project, build the bounded + brief, construct the exact Claude CLI command, and (only with + `--launch`) start it in a **new** tmux session; +- `fable-session watch` — monitor exactly one launched session's + structured JSONL stream and report lifecycle events (pauses and + refusals included) without ever acting on the session; +- `fable-session audit` — the post-run model audit: prove from the + session's own transcript which models actually served it. + +Every option, guarantee, and edge case is specified in the +[Reference](docs/REFERENCE.md). + +## Privacy in short + +Run manifests and command output carry hashes, byte sizes, and paths — +never prompt text, credentials, or terminal history. Never put secrets +in a task file: while a session runs, prompt material is readable by +processes of your own user on the same host, and secret-shaped content +is rejected before launch. The exact payload mechanism and its limits +are documented in the +[Reference](docs/REFERENCE.md#privacy-and-metadata). + +## Documentation + +- [Quickstart](docs/QUICKSTART.md) — your first session, end to end: + checkout, registry, profile, task, dry run, launch, watch, audit; +- [Reference](docs/REFERENCE.md) — the full lifecycle detail: + configuration, run state, audit verdicts, watchdog events, privacy + contract, migration from claude-context-tools, development, and + release notes; +- [Security policy](SECURITY.md) — how to report a vulnerability + (GitHub private vulnerability reporting only); +- [examples/projects.toml](examples/projects.toml) — a commented + registry example. ## Status and license @@ -649,11 +179,3 @@ only after the release-status PR merges and protected-main CI passes, and only a successful run of the tag workflow creates the prerelease. The ORIGINAL pre-export working history must never be pushed publicly. Security reports: see `SECURITY.md`. - -## Out of scope - -Installation into `~/.local/bin` on real hosts, systemd units/timers or any -other scheduling, cron jobs, multi-host/fleet distribution, retry or -notification automation, host-wide or multi-lane watching, and any form of -safeguard circumvention (which is out of scope permanently, not just for -now). diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..0f1f72a --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,162 @@ +# Quickstart: your first bounded session + +This walkthrough goes end to end once: install from a checkout, register +one project, add its profile, write one task, dry-run it, launch it, +watch it, and audit it. Every path is a safe placeholder — replace +`/srv/example-project` with your own repository. Nothing here needs +credentials, and no step ever runs the Claude CLI by hand: the runner +builds and launches the exact command for you. + +Before you start, read the two notices at the top of the +[README](../README.md): fable-session is unofficial (not affiliated with +Anthropic) and does not bypass safeguards. You also need the +[prerequisites](../README.md#prerequisites): Linux, Python 3.12+, tmux, +and a separately installed and authenticated Claude Code CLI. + +## 1. Install from a checkout + +The `v0.3.0-beta.1` tag is not pushed yet, so today you install from a +checkout of the public repository (the tag-pinned commands in the +[README](../README.md#install) start working once the tag exists): + +```bash +git clone https://github.com/getaskclaw/fable-session +cd fable-session +python3.12 -m venv ~/.venvs/fable-session +~/.venvs/fable-session/bin/pip install . +export PATH="$HOME/.venvs/fable-session/bin:$PATH" + +fable-session --version # 0.3.0b1 +``` + +## 2. Register your project + +The host registry tells fable-session where your repository lives and +which model serves it. Create `~/.config/fable-session/projects.toml`: + +```bash +mkdir -p ~/.config/fable-session +``` + +```toml +[project.example-api] +repo = "/srv/example-project" +profile = "agent-context/profiles/fable-5.toml" +model = "claude-fable-5" +effort = "high" +fallback = "stop" +permission_mode = "auto" +tmux_prefix = "api-" +``` + +Two lines matter most for a first run: `model`/`effort` pin Fable 5 at +high effort for every session of this project, and `fallback = "stop"` +means no automatic fallback — if Fable 5 cannot serve the session, the +session stops instead of silently switching models. No budget key is set +here, so the launch carries no budget flag at all (the optional budget +key is described in the [Reference](REFERENCE.md#configuration)). + +## 3. Add the repo-owned profile + +The profile lives inside your repository and describes it in a few +lines. Create `/srv/example-project/agent-context/profiles/fable-5.toml`: + +```toml +version = 1 +product = "Example REST API for a small to-do application." +context_mode = "append" +max_brief_bytes = 2048 +allowed_roots = ["src", "tests", "docs"] +``` + +`max_brief_bytes` is the hard byte budget: the rendered brief must fit in +it, which is exactly how fable-session keeps the context bounded. + +## 4. Write a task file + +One task file per session, with four required sections. Create +`/srv/example-project/tasks/add-healthcheck.md`: + +```markdown +# Task: add a /health endpoint + +## Goal +Add a small HTTP health-check endpoint that returns 200 and "ok". + +## Checks +- a unit test covers the new endpoint + +## Boundaries +- touch only src/ and tests/ + +## Report +- name the files changed and the test added +``` + +Never put secrets in a task file — secret-shaped content aborts the run +before anything launches. + +## 5. Dry-run it (starts nothing) + +A dry run is the default and launches nothing: + +```bash +fable-session run --project example-api \ + --task /srv/example-project/tasks/add-healthcheck.md --dry-run +``` + +It prints the resolved project, the pinned model/effort/fallback, the +brief size, and the **redacted** launch command, and it writes a run +manifest under `~/.local/state/fable-session/runs//manifest.json`. +Read the output until it matches what you expect — nothing has started. + +## 6. Launch it + +Launching is always explicit: add `--launch` and name the tmux session +(the name must start with your project's `tmux_prefix`): + +```bash +fable-session run --project example-api \ + --task /srv/example-project/tasks/add-healthcheck.md \ + --launch --tmux api-healthcheck +``` + +The session starts inside a **new** tmux session named +`api-healthcheck`. Copy the manifest path the launch prints — the next +two commands take exactly that path. (A launch gets a fresh random +run id, so it is not the same path your dry run printed.) + +## 7. Watch it (one scan) + +Point the watchdog at the launch manifest for a single scan: + +```bash +fable-session watch \ + --manifest "$HOME/.local/state/fable-session/runs//manifest.json" \ + --once +``` + +Silence means nothing new happened. Each confirmed lifecycle event is one +compact JSON line — `completed` when the session finished, or +`refusal_pause` if Fable 5's safeguards genuinely paused it. A real pause +is recorded and the run stops: fable-session does not continue a paused +run, does not edit or retry a flagged prompt, and does not switch models. +What happens next in that tmux window is your decision as a human. + +## 8. Audit it + +After the session finishes, prove which model actually served it — from +the session's own JSONL evidence, using the same manifest: + +```bash +fable-session audit \ + --manifest "$HOME/.local/state/fable-session/runs//manifest.json" +``` + +The model audit answers with `PURE` (only `claude-fable-5` served it), +`MIXED` (another model provably served part of it), or `UNKNOWN` (the +evidence is incomplete — never guessed). Exit codes 0/1/2 match those +verdicts, so scripts can rely on them. + +That is the whole lifecycle: run, watch, audit. Every option, guarantee, +and edge case lives in the [Reference](REFERENCE.md). diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md new file mode 100644 index 0000000..a10746c --- /dev/null +++ b/docs/REFERENCE.md @@ -0,0 +1,575 @@ +# fable-session reference + +This is the full operator/agent reference for fable-session: every +command, option, guarantee, and edge case. If you are new here, start +with the [README](../README.md) and the [Quickstart](QUICKSTART.md) +instead — and keep their two notices in mind: fable-session is +unofficial (not affiliated with Anthropic) and does not bypass +safeguards. + +## Commands + +One console command, three subcommands: + +- `fable-session run` — resolve a registered project, build a bounded + brief, construct the exact Claude CLI command, and (only on explicit + request) launch it in a **new** tmux session. +- `fable-session audit` — post-run model-purity audit: stream one completed + session's JSONL transcript and report which models actually served it. +- `fable-session watch` — a JSONL-first monitor for exactly ONE + runner-created lane (one manifest). It stays silent when nothing changes, + emits one compact JSON object per newly confirmed structured lifecycle + event, and in `--follow` mode exits by itself at the lane's terminal + state. It never scans hosts, projects, panes, or PIDs, and never acts on + the session. + +Runtime dependencies: Python 3.12 standard library only. Installation is +covered in the [README](../README.md#install). + +## Dry run (default — starts nothing) + +```bash +fable-session run --project example-api --task /absolute/path/task.md --dry-run +``` + +`--dry-run` is the default; omitting all launch flags behaves identically. +The dry run: + +- resolves the project from `~/.config/fable-session/projects.toml` + (override with `--registry`); +- loads the repo-owned profile (`agent-context/profiles/*.toml`); +- builds the bounded brief from the packaged `bounded-worker.md` template + (importable package data — no dependency on the source checkout); +- writes a run manifest under + `~/.local/state/fable-session/runs//manifest.json` + (override with `--state-dir`); +- prints project, model/effort/fallback, repo, branch, task, brief mode, + session id, the future structured-stream path and native JSONL transcript + path (described only — a dry run creates no transcript), manifest path, + and the **redacted** launch command. + +Dry runs are deterministic: identical registry/profile/task inputs produce +the identical run id, session id, and manifest. + +## Explicit launch + +```bash +fable-session run --project example-api --task /absolute/path/task.md \ + --launch --tmux api-example +``` + +- `--launch` is required to start anything; `--tmux` names the new session. +- The full session name is strictly validated (`[A-Za-z0-9_-]`, max 64 + chars — `:` and `.` are tmux target syntax and are rejected) and must + start with the project's `tmux_prefix` (same character set). +- The tool only ever creates a **new** tmux session (exact-name match); if + the name already exists it fails and starts nothing. It never uses + `--continue` or `--resume`. +- Model, effort, and permission mode are pinned from the registry; + `--fallback-model` and `--dangerously-skip-permissions` are never emitted + (config accepts only `fallback = "stop"` and an allowlisted + `permission_mode`; `bypassPermissions` is rejected at parse time). +- `fallback = "stop"` is enforced, not just documented: every command + carries `--settings '{"switchModelsOnFlag": false}'`, so the pinned model + either serves or the session stops — it never switches silently. +- Only the verified prompt flags `--append-system-prompt` (profile + `context_mode = "append"`) and `--system-prompt` (`"replace"`) are used; + no file-based prompt flags. Since 0.3.0b1 the prompt **payloads** never + ride the tmux command line: the runner writes the brief and task as + run-scoped mode-0600 payload files (`brief.payload`, `task.payload` + inside the run directory) from its single scanned read, and the capture + wrapper — after verifying each payload's sha256 against the hash + recorded in the manifest — substitutes the brief for a single + placeholder token in the Claude argv and feeds the task to Claude on + stdin (`-p` with no inline prompt). This also repairs the verified + launch defect where a 7,745-byte task plus an 8,072-byte brief failed + with tmux's `command too long`: the tmux command stays short no matter + how large the (bounded) payloads are. +- If configured, the registry's `max_budget_usd` is passed as exactly one + `--max-budget-usd` argument, shown in the launch summary, and recorded + in the manifest; with no budget configured, no budget flag is emitted + and the manifest records `max_budget_usd: null`. The Claude CLI's own + budget enforcement is the mechanism — fable-session never kills or + stops a session itself. +- **Terminal evidence is captured, not inferred.** Claude runs with + `--output-format stream-json --verbose` under a shell-free capture + wrapper (`capture.py`, executed by absolute path — no `bash -c`, no + pipeline, no redirection strings). The wrapper creates the run-scoped + stream file `/stream.jsonl` exclusively (mode 0600; an + existing/symlinked destination fails closed before Claude is invoked), + hands it to Claude as stdout so the structured stream — including the + top-level terminal `result` event — lands byte-for-byte, and propagates + Claude's exit status. The manifest's `expected_transcript_path` names + this stream (what `fable-session watch` and `fable-session audit` + consume); `native_transcript_path` separately preserves Claude's native + session JSONL, which never contains the terminal `result` and is for + diagnosis only. The stream is private transcript data: it may contain + prompt and result text and is never echoed to runner output or the + manifest. + +## Launch identity and run state + +- Each launch gets a high-entropy run id + (`--`) and an independent random + Claude session id; identical launches in the same second can never share a + run directory, manifest, or JSONL transcript identity. +- The run directory is reserved atomically (`mkdir` with `exist_ok=False`, + retried with fresh entropy on collision) and a `pending` manifest is + written **before** tmux starts. If reservation or the pending write fails, + nothing is launched. +- After `tmux new-session` succeeds the manifest is atomically updated to + `launched`; if tmux fails it records `failed`. If a post-launch update + fails **before** its publish rename, the `pending` record remains on the + reservation-bound run-directory inode and already identifies the actor, + model, session id, and tmux session. +- Manifest paths in output and manifests are **reservation-time display + paths**. All writes go through the run-directory FD held since + reservation, so they always land in the reserved inode — but a same-UID + process can rename that directory despite the open FD, so the display + path may later stop resolving to it (and may even name a planted + replacement). Do not treat the display path as the manifest's current + location without re-proving its identity. +- Post-publish durability failure (`StateCommitUncertain`): if the + run-directory fsync *after* a successful publish rename fails, the new + complete manifest was already atomically published to the bound + run-directory inode and the previous one is gone from it — only + durability across a crash or power loss is unconfirmed. Errors report + the bound `(dev, ino)` taken from the held FD (never re-read from the + path) and label the reservation-time path as display-only, so recovery + tooling is never pointed at a possibly planted replacement. +- Run ids are re-validated defensively in the state layer: path separators, + `.`/`..`, control characters, and symlinked run directories can never + escape the canonical `runs/` root. +- The state directory path is interpreted lexically and trusted only via + directory file descriptors: every component is created/opened + component-by-component with `O_DIRECTORY | O_NOFOLLOW` from the filesystem + root. A symlink anywhere in the state path — including the state directory + itself and its `runs` child — is rejected as a `StateError`; symlinked + state directories are unsupported by contract (point `--state-dir` at the + real path). No write ever re-traverses a path after trust is established. +- Manifest updates are atomic and writer-private: each writer stages its + payload in its own unpredictable `.manifest-.tmp` + (`O_CREAT | O_EXCL | O_NOFOLLOW`), fully written, fsynced, and closed + before an `os.replace` publish relative to the trusted run-dir FD. + Concurrent writers are last-writer-wins; a reader can never observe a + partial or mutable-after-publish `manifest.json`. A crashed writer may + leave a stale `.manifest-*.tmp`, which other writers never adopt or + delete. + +## Post-run model audit + +The runner pins the model and disables model switching, but its manifest +proves only *intent*. `fable-session audit` checks the *evidence*: it +streams one completed Claude Code JSONL transcript and reports observed +model purity. It is generic — any registered project — with no +project-specific logic. + +```bash +# Explicit source +fable-session audit \ + --transcript /absolute/path/session.jsonl \ + --requested-model claude-fable-5 + +# Runner manifest source (reads `model` and `expected_transcript_path`; +# never mutates the manifest) +fable-session audit --manifest /absolute/path/manifest.json + +# Machine-readable +fable-session audit --manifest /absolute/path/manifest.json --format json +``` + +The two input modes are mutually exclusive. Every input path must be an +absolute, existing regular file; symlinks are rejected, not followed. + +What it reports: + +- **observed message models** — non-synthetic `message.model` values from + assistant entries, deduplicated by unique `message.id` (streamed/chunked + entries count once), as unique-message-id counts per model; +- **final response model** — the model of the last unique assistant message + with non-empty text content (`—`/`null` when unprovable, which forfeits + PURE); +- **synthetic messages** — `` entries are counted separately and + never prove a serving model; +- **auxiliary models** — result `modelUsage` keys beyond the observed + message models. Helper-model use (e.g. a smaller model doing + summarization) is reported honestly but does **not** by itself make a run + MIXED; +- **fallback/refusal events** — structured system events such as subtype + `model_refusal_fallback`, with any serving model they identify; +- **result metadata** — safe fields only (subtype, `is_error`, stop reason); +- **usage (0.3.0b1)** — a privacy-safe aggregate of the single terminal + result's `modelUsage` across all models: `input_tokens`, + `output_tokens`, `cache_read_input_tokens`, + `cache_creation_input_tokens`, `web_search_requests`, and + `total_cost_usd` — bounded numbers only, never prompt, result, or tool + text. When `modelUsage` is missing, or there is no single trustworthy + terminal result, `usage` is an explicit `null` (text output says + `usage: unavailable`) — zeroes are never invented. A structurally + malformed `modelUsage` (wrong types, negative or non-finite numbers) + additionally sets the reason code `malformed_model_usage` and forfeits + PURE: corrupt optional usage never falsely proves model purity; +- **terminal-result completeness** — a completed transcript carries exactly + one top-level `type="result"` event with `subtype: "success"` and boolean + `is_error: false`. A missing result (interrupted stream), more than one + result (concatenated runs — none is silently chosen; `result` is `null`), + or an unsuccessful/ambiguous result is an evidence-integrity failure + (`missing_terminal_result`, `multiple_terminal_results`, + `unsuccessful_terminal_result`). Completion is never inferred from the + last assistant text, status events, or anything outside the structured + result; `stop_reason` is reported but not required; +- **reason codes** — bounded machine-safe diagnostics (e.g. + `malformed_json_line`, `conflicting_duplicate_message_id`, + `missing_terminal_result`). + +Verdicts, for requested model `R`. PURE and MIXED both require exactly one +successful terminal result; a missing, multiple, or unsuccessful result is +UNKNOWN: + +- `PURE` (exit 0) — parsing complete and valid, exactly one successful + terminal result, at least one non-synthetic message observed, every + observed message model and the proven final response model are exactly + `R`, and no fallback/refusal event occurred. +- `MIXED` (exit 1) — complete evidence (including exactly one successful + terminal result) proves an observed non-synthetic message model other + than `R`, or a fallback/refusal event identifies a different serving + model. A later return to `R` stays MIXED. +- `UNKNOWN` (exit 2) — purity cannot be proven: missing, malformed, partial, + or conflicting evidence (duplicate id with conflicting models, truncated + trailing JSON, model-bearing message without an id), a missing, multiple, + or unsuccessful terminal result, synthetic-only transcripts, no provable + final response model, or a fallback/refusal without enough serving-model + evidence to classify MIXED. Evidence-integrity failures always win over a + tempting PURE/MIXED conclusion — an interrupted or concatenated stream is + UNKNOWN even when all observed messages match `R` or a different model + would otherwise prove MIXED. + +Exit code 2 is also used for usage, file, and manifest errors. Neither +output format ever contains prompts, result free text, tool payloads, or +any other transcript content; the JSON object (stable keys, +`schema_version: 1`) carries no filesystem paths. + +## Exact-lane session watchdog + +`fable-session watch` monitors exactly one runner-created lane, identified +by the absolute path of its run manifest. It is read-only with respect to +the lane: it never presses keys, pastes text, retries, switches models, +creates prompts, kills sessions, or touches product files — it reads, +classifies, and reports. + +```bash +# One scan, then return +fable-session watch --manifest /abs/runs//manifest.json --once + +# Poll only this lane's transcript; exits by itself at the terminal state +fable-session watch --manifest /abs/runs//manifest.json \ + --follow --poll-interval 2 --settle-seconds 10 +``` + +Inputs fail closed (exit 2): the manifest and the transcript it names via +`expected_transcript_path` must each be an absolute, existing, regular, +non-symlinked file (the transcript is re-verified on every scan). The lane +identity is the manifest's `session_id` (required) plus `run_id` and +`tmux_session` (optional); a transcript carrying any other session id is a +lane mismatch and is refused. There is no global or multi-lane mode — no +scanning of other projects, tmux panes, PIDs, home directories, or hosts. + +**Events.** Evidence rules are shared with the model audit (one truth +model): only structured top-level JSONL entries are events; quoted warning +text inside ordinary assistant/user message content (a message that merely +*says* "Session paused") never is. Each newly confirmed event is one +compact JSON object on stdout (`schema_version: 1`, session/run identity, +bounded structural fields only); when there is nothing new, stdout is +empty. + +- `refusal_pause` — structured system event whose subtype names a refusal + or pause; +- `model_fallback` — structured system fallback event (a subtype naming + both, e.g. `model_refusal_fallback`, counts as the fallback), with any + serving model it identifies; +- `permission_block` — structured system permission-block event; +- `malformed_evidence` — lifecycle-relevant integrity failure (malformed + JSON line, non-object line, conflicting session-id fields) before any + terminal result; diagnostic, not terminal; +- `completed` — exactly one successful terminal result (`subtype: + "success"`, boolean `is_error: false`); **terminal**. When the result + carries a structurally valid `modelUsage`, the event includes the same + bounded `usage` aggregate as the audit (tokens, web-search requests, + `total_cost_usd`) — and makes no usage claim otherwise; +- `completed_error` — exactly one unsuccessful (or ambiguous-fields) + terminal result; **terminal**; carries the bounded `usage` aggregate + under the same rule; +- `completed_ambiguous` — more than one terminal result, or a terminal + result on malformed evidence (integrity failures win over a tempting + completion verdict, exactly as in the audit); **terminal**; +- `interrupted` — `--follow` only, see below; **terminal**. + +A missing terminal result is never called complete. An unterminated final +JSONL line is an in-progress write: tolerated until it completes, never +malformed evidence. + +**Silence and dedupe.** Dedupe state persists per lane (default +`watchdog.state.json` next to the manifest; override with an absolute +`--state-file`), keyed by session id plus event identity, so repeated scans +of an unchanged transcript emit nothing. Writes are atomic (exclusive +temp + fsync + rename) and contain only the schema version, lane identity, +and emitted event keys — never prompts, tool payloads, credentials, or +transcript content. A state file belonging to a different session fails +closed; separate sessions can never collide. The repo's documented +same-UID limitation applies to this state exactly as to run manifests. + +**Auto-exit and interruption.** `--follow` polls only this manifest's +transcript and exits after emitting/deduping the lane's terminal event — +it never stays resident after completion. An interruption is claimed only +when ALL of the following hold: the manifest names a tmux session, an +exact-name probe (`tmux has-session -t =NAME`, never a scan) says it is +gone, the transcript stayed byte-stable across consecutive polls for at +least the settle window (`--settle-seconds`, default 10.0), and no +terminal result exists. Then exactly one `interrupted` event is emitted +and the watchdog exits 1. A failed probe proves nothing and never supports +an interruption claim; with no `tmux_session` in the manifest, +interruption detection is disabled and only a terminal result ends the +follow. Start `--follow` after the transcript file exists — a missing +transcript is a rejected input, not a wait state. + +**Exit codes (stable).** + +- `0` — clean: `--once` scanned with the lane in progress or successfully + completed; `--follow` exited on the successful terminal result. +- `1` — the lane reached a confirmed non-success terminal state: + `completed_error`, `completed_ambiguous`, or `interrupted`. +- `2` — usage, input-validation, or state error (bad flags; missing, + relative, malformed, non-regular, or symlinked manifest / transcript / + state inputs; lane/session mismatch; state write failure). Nothing was + concluded about the lane. + +## Configuration + +Host registry (`~/.config/fable-session/projects.toml`, see +[examples/projects.toml](../examples/projects.toml)): + +```toml +[project.example-api] +repo = "/srv/example-project" +profile = "agent-context/profiles/fable-5.toml" +model = "claude-fable-5" +effort = "high" +fallback = "stop" +permission_mode = "auto" +tmux_prefix = "api-" +# Optional (0.3.0b1): enforceable per-session budget, passed to Claude as +# exactly one `--max-budget-usd 12.5`. Must be a finite positive TOML +# number with a plain-decimal form; booleans, zero, negatives, NaN, +# infinities, strings, and exponent-only representations are rejected +# before any command is built. Omit the key for no budget flag at all. +max_budget_usd = 12.5 +``` + +`permission_mode` is required and allowlisted (`acceptEdits`, `auto`, +`manual`, `dontAsk`, `plan` — the installed Claude CLI's choices minus +`bypassPermissions`); it is passed explicitly as `--permission-mode` so the +session never inherits an ambient permission policy. `bypassPermissions` and +dangerous-skip flags are rejected outright, as is the unsupported `default`. + +Repo-owned profile (`/agent-context/profiles/fable-5.toml`): + +```toml +version = 1 +product = "Internal data dashboard built with Vue and FastAPI." +context_mode = "append" +max_brief_bytes = 2048 +allowed_roots = ["backend", "frontend", "tests", "docs"] +``` + +Parsing is strict: unknown or secret-like keys, secret-shaped values, +relative repo paths, unsupported profile versions, and `allowed_roots` +escaping the repo all fail before any command is built. Project names are +strict identifiers (`[A-Za-z0-9][A-Za-z0-9_-]{0,31}`) because they flow +into run ids and state paths. The profile path is resolved and must stay +inside the repo even through symlinks. Project/model differences live +entirely in these TOML files — the runner has no project-specific behavior. + +Task and profile files are read exactly **once** per run: the same immutable +text is parsed, secret-scanned, hashed, rendered into the brief, and passed +to Claude, so a file changing on disk mid-run cannot smuggle unscanned +content into the launched command. + +## Task file format + +```markdown +# Task: short title + +## Goal +One short paragraph. + +## Checks +- one bullet per required check + +## Boundaries +- one bullet per boundary + +## Report +- one bullet per report element + +## Docs (optional) +- path/to/canonical-doc.md +``` + +Goal, Checks, Boundaries, and Report are required. Docs entries must be bare +paths — canonical docs are referenced, never copied into the brief. The +rendered brief must fit in the profile's `max_brief_bytes`. Secret-shaped +content anywhere in the task file or brief aborts the run. + +## Privacy and metadata + +Run manifests and user-facing output contain hashes, byte sizes, and paths — +never brief/prompt contents, credentials, or terminal history. The manifest +also records the effective permission mode and no-fallback settings, plus +the expected Claude Code JSONL transcript path +(`~/.claude/projects//.jsonl`; the session id is +passed to Claude via `--session-id`). + +Prompt payload handling (0.3.0b1): the tmux command line never carries +prompt text — the brief and task travel as run-scoped mode-0600 payload +files (`brief.payload`, `task.payload` in the run directory), written from +the single scanned read and verified by sha256 before delivery. The task +reaches Claude on **stdin** and appears on no process command line at all. +The **brief** is substituted into the Claude argv at exec time, so it +**remains visible to same-host process inspection** (e.g. `ps`, +`/proc//cmdline`) of the Claude process for its lifetime, and the +payload files themselves are readable by the same UID. Do not put anything +in a task file that other users of the same host must not see; +secret-shaped content is rejected before launch. A dry run writes only the +manifest — it leaves no payload material behind, and its manifest records +`payload_files_created: false` (payload paths are descriptive only). A +launch manifest carries `payload_files_created: true` only in records +written after **both** run-scoped payload files were actually created; +records written before that point (the `pending` record, or a `failed` +record after a payload-write failure) truthfully say `false`. Manifests +from earlier releases lack the key and stay fully readable. + +## Migrating from claude-context-tools 0.1.x + +Version 0.2.0 renamed the distribution, import package, CLI, manifests, and +default paths. Nothing migrates implicitly: fable-session never reads, +adopts, or mutates the old directories. + +| 0.1.x | 0.2.0 | +| --- | --- | +| distribution `claude-context-tools` | `fable-session` | +| import package `claude_context_tools` | `fable_session` | +| `claude-context-run` | `fable-session run` | +| `claude-context-audit-models` | `fable-session audit` | +| `claude-session-watchdog` | `fable-session watch` | +| `~/.config/claude-context/` | `~/.config/fable-session/` | +| `~/.local/state/claude-context/` | `~/.local/state/fable-session/` | +| manifest `tool: "claude-context-run "` | `tool: "fable-session "` | + +Explicit migration steps: + +1. Copy your registry yourself: + `cp ~/.config/claude-context/projects.toml ~/.config/fable-session/projects.toml` + (or pass `--registry` explicitly). The file format is unchanged. +2. Old run state stays where it is and stays readable: `fable-session + audit --manifest ` and `fable-session watch --manifest ` accept 0.1.x manifests when you point at them explicitly — + nothing keys off the manifest's `tool` string. New runs write only under + the new state directory (or your `--state-dir`). +3. Deterministic dry-run ids changed with the rename (the id namespace is + now `fable-session`), so a 0.2.0 dry run of identical inputs produces a + different run/session id than 0.1.x did. Launch ids were always random. + +Rollback: uninstall 0.2.0 and reinstall 0.1.x. Because 0.2.0 never touches +the old config/state directories, a rollback finds them exactly as 0.1.x +left them; delete `~/.config/fable-session/` and +`~/.local/state/fable-session/` if you want no trace of the trial. + +The three old console names (`claude-context-run`, +`claude-context-audit-models`, `claude-session-watchdog`) were kept for +exactly one migration release (0.2.x) as deprecated compatibility aliases +and are **removed in 0.3.0b1**: they are no longer installed and no longer +exist as entry points. Use `fable-session run|audit|watch`. Reading old +manifests is unaffected — `fable-session audit --manifest` and +`fable-session watch --manifest` still accept 0.1.x manifests when pointed +at them explicitly. There is deliberately no short alias — nothing named +`fs` is ever installed. + +## Development + +```bash +python3 -m compileall -q src tests +python3 -m unittest discover -s tests -v +``` + +Unit tests never call the Claude API or start real tmux sessions; subprocess +boundaries are mocked. `--registry`, `--state-dir`, and `--claude-bin` exist +so tests and fixtures never touch real host state. All test fixtures are +synthetic. + +The brief template ships inside the package +(`src/fable_session/templates/bounded-worker.md`, declared as setuptools +package data) and is loaded via `importlib.resources`, so an installed +distribution renders briefs without the source checkout. The offline +install smoke proves it end to end — disposable venv under `/tmp`, +`pip install --no-build-isolation --no-deps --no-index`, installed console +commands, and one sanitized dry-run from outside the checkout: + +```bash +python3 tests/offline_install_smoke.py +``` + +In restricted CI environments the smoke accepts an explicitly pre-seeded +wheelhouse for its offline build backend: point `FABLE_SESSION_WHEELHOUSE` +at a directory containing a `setuptools>=77` wheel downloaded in an +earlier, clearly network-allowed step; the install phase itself still runs +with `--no-index --no-build-isolation --no-deps`. + +The public-readiness check scans the tracked tree (naming, notices, no +internal identifiers, no credential-shaped literals), and its full-history +sibling applies the same policy to every tracked blob of every commit +reachable from HEAD; both run locally and in CI: + +```bash +python3 tests/public_readiness_check.py +python3 tests/full_history_readiness_check.py +``` + +## Release notes + +**0.3.0b1** (public beta 1; the matching GitHub prerelease tag is +`v0.3.0-beta.1`): + +- optional registry key `max_budget_usd`: an enforceable Claude budget + passed as exactly one `--max-budget-usd` argument, printed in the + summary and recorded as structural manifest metadata; +- command-length/privacy repair: prompt payloads moved off the tmux + command line into hash-verified run-scoped 0600 payload files (brief via + placeholder substitution, task via stdin), fixing the `command too long` + launch failure for large task/brief pairs; +- privacy-safe usage reporting: bounded `modelUsage` aggregates (tokens, + web-search requests, `total_cost_usd`) in `audit --format json`, audit + text output, and terminal `watch` events — explicit `null` instead of + invented zeroes, and malformed usage never falsely proves purity; +- the three deprecated 0.1 console aliases are removed after their one + migration release; +- public packaging metadata (SPDX MIT, README long description, honest + classifiers), pinned CI actions, a full-history readiness scan, and a + tag-triggered attested prerelease workflow. + +## Contributing + +Small, focused contributions are welcome — the repository is public: +open an issue or PR at the official repository. Keep changes test-first +(`python3 -m unittest discover -s tests`), offline, stdlib-only, and +within the documented guarantees — anything that weakens the no-fallback, +permission, secret-scanning, or evidence-integrity contracts (or tries to +bypass safeguards) will not be accepted. Security reports: +[SECURITY.md](../SECURITY.md), never a public issue. + +## Out of scope + +Installation into `~/.local/bin` on real hosts, systemd units/timers or any +other scheduling, cron jobs, multi-host/fleet distribution, retry or +notification automation, host-wide or multi-lane watching, and any form of +safeguard circumvention (which is out of scope permanently, not just for +now). diff --git a/docs/assets/fable-5-session-paused.jpg b/docs/assets/fable-5-session-paused.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f13440d3f3ff0e6774f84755296a86eca4fe26db GIT binary patch literal 26049 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 P_V-c1IYoGGW}Nwd None: ".github/workflows/ci.yml"): if not (REPO / path).is_file(): findings.append(f"{path} missing") + check_beginner_readme(findings, text, plain) + + +def check_beginner_readme(findings: list[str], text: str, + plain: str) -> None: + """Beginner-first landing-page contract: plain-words explanation, the + preserved Session paused screenshot with an indexable transcription, + explicit no-bypass behavior, and links into docs/.""" + flat = " ".join(plain.split()) + for needle in ("local python wrapper", "not a model", + "not an anthropic product"): + if needle not in flat: + findings.append( + f"README.md: beginner explanation missing ({needle!r})") + screenshot = "docs/assets/fable-5-session-paused.jpg" + if screenshot not in text: + findings.append( + "README.md: the Session paused screenshot " + f"({screenshot}) is not referenced") + for needle in ("session paused", + "fable 5's safeguards flagged this message", + "1. switch to opus 4.8", + "2. edit prompt and retry with fable 5"): + if needle not in flat: + findings.append( + "README.md: indexable Session paused transcription " + f"missing ({needle!r})") + for needle in ("does not continue a paused run", + "does not switch models", + "a real pause is recorded and the run stops"): + if needle not in flat: + findings.append( + f"README.md: no-bypass behavior wording missing ({needle!r})") + for link in ("docs/QUICKSTART.md", "docs/REFERENCE.md"): + if link not in text: + findings.append(f"README.md: link to {link} missing") + for path in ("docs/QUICKSTART.md", "docs/REFERENCE.md", screenshot): + if not (REPO / path).is_file(): + findings.append(f"{path} missing") def main() -> int: diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index 02891e5..2862877 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -19,6 +19,7 @@ references and must never be pushed publicly (PUBLIC_RELEASE_CHECKLIST.md). """ +import hashlib import importlib.util import re import shutil @@ -30,6 +31,17 @@ REPO = Path(__file__).resolve().parents[1] +# Beginner-first documentation layout: README is the landing page, the +# deep operator/agent material lives in docs/, and the user-supplied +# "Session paused" screenshot is preserved byte-for-byte. +QUICKSTART = REPO / "docs" / "QUICKSTART.md" +REFERENCE = REPO / "docs" / "REFERENCE.md" +SCREENSHOT_RELPATH = "docs/assets/fable-5-session-paused.jpg" +SCREENSHOT = REPO / SCREENSHOT_RELPATH +SCREENSHOT_SHA256 = ( + "7df5717543f0b5b6853b0e302708d2f68c1b251a7f72719e4e2a927b2e86a54f" +) + # Internal identifiers, composed so this file never contains them literally. INTERNAL_NEEDLES = [ "compute" + "box", # internal host user/home @@ -271,9 +283,9 @@ def test_examples_use_only_the_canonical_cli(self): class TestPublicBetaDocs(unittest.TestCase): """0.3.0b1 README/SECURITY contract: real installation guidance, honest - prerequisites and platform claims, budget/usage documentation, the - prerelease tag name, the updated payload privacy contract, and the - completed alias removal.""" + prerequisites and platform claims, and the prerelease tag name. The + deep payload/budget/usage/migration contracts moved with their prose to + docs/REFERENCE.md and are enforced there (TestReferenceDoc).""" def read(self, name="README.md"): return (REPO / name).read_text(encoding="utf-8") @@ -288,15 +300,19 @@ def test_readme_has_an_installation_section(self): self.assertIn(f'uv tool install "fable-session @ {pinned}"', text) self.assertIn(f'pip install "fable-session @ {pinned}"', text) - def test_readme_never_recommends_unqualified_index_installs(self): + def test_no_doc_recommends_unqualified_index_installs(self): # Until the name is actually owned/published on the package index, - # an unqualified install could fetch nothing — or a squatter. - text = self.read() - for banned in ("pipx install fable-session", - "uv tool install fable-session", - "pip install fable-session"): - self.assertNotIn(banned, text, - f"unqualified command recommended: {banned!r}") + # an unqualified install could fetch nothing — or a squatter. The + # ban covers every user-facing doc, not just the README. + for path in (REPO / "README.md", QUICKSTART, REFERENCE): + text = path.read_text(encoding="utf-8") + for banned in ("pipx install fable-session", + "uv tool install fable-session", + "pip install fable-session"): + self.assertNotIn( + banned, text, + f"{path.name}: unqualified command recommended: " + f"{banned!r}") def test_readme_pre_index_warning_precedes_every_install_command(self): text = self.read() @@ -315,10 +331,6 @@ def test_readme_pre_index_warning_precedes_every_install_command(self): self.assertIn("only after", text) self.assertIn("tag exists", text) - def test_readme_documents_the_payload_created_flag(self): - text = self.read() - self.assertIn("payload_files_created", text) - def test_readme_lists_prerequisites_and_the_tested_combination(self): text = self.read() self.assertIn("Python 3.12", text) @@ -330,22 +342,175 @@ def test_readme_lists_prerequisites_and_the_tested_combination(self): for banned in ("Windows", "macOS", "works everywhere"): self.assertNotIn(banned, text) - def test_readme_documents_budget_pass_through(self): + def test_readme_names_the_prerelease_tag(self): + text = self.read() + self.assertIn("0.3.0b1", text) + self.assertIn("v0.3.0-beta.1", text) + + def test_security_supports_the_0_3_line(self): + text = self.read("SECURITY.md") + self.assertIn("0.3.x", text) + self.assertNotIn("(0.2.x)", text) + + +def _norm(text): + """Lowercase, bold markers stripped, whitespace (incl. wraps) + collapsed — for prose needles that may wrap across lines.""" + return " ".join(text.replace("**", "").lower().split()) + + +class TestBeginnerReadme(unittest.TestCase): + """The README is a beginner-first landing page: one-sentence + explanation, the preserved 'Session paused' screenshot with its + visible message as indexable plain text, explicit + what-it-does/does-not-do framing, and links into docs/.""" + + def read(self): + return (REPO / "README.md").read_text(encoding="utf-8") + + def test_readme_stays_a_short_landing_page(self): + # Roughly 150-250 lines; a loose band, never an exact count. + lines = len(self.read().splitlines()) + self.assertGreaterEqual(lines, 100, "landing page suspiciously bare") + self.assertLessEqual( + lines, 300, + "README grew past a beginner landing page — move deep " + "material to docs/REFERENCE.md") + + def test_readme_opens_with_a_one_sentence_beginner_explanation(self): + text = _norm(self.read()) + for needle in ("local python wrapper", + "claude code", + "not a model", + "not an anthropic product"): + self.assertIn(needle, text, + f"beginner explanation missing: {needle!r}") + + def test_readme_shows_the_screenshot_near_the_top(self): + text = self.read() + self.assertIn("Have you seen this?", text) + self.assertIn(SCREENSHOT_RELPATH, text) + # "Near the top": before the Install section, not buried below it. + self.assertLess(text.find(SCREENSHOT_RELPATH), + text.find("## Install"), + "screenshot must appear before the Install section") + + def test_readme_transcribes_the_screenshot_as_indexable_text(self): + text = self.read() + # Exact numbered options, preserved verbatim. + self.assertIn("1. Switch to Opus 4.8", text) + self.assertIn("2. Edit prompt and retry with Fable 5", text) + # The visible message, wrap-tolerant. + flat = _norm(text) + for needle in ( + "session paused", + "fable 5's safeguards flagged this message", + "the safeguards are intentionally broad right now and may " + "flag safe and routine coding, cybersecurity, or biology " + "work", + "these measures let us bring you mythos-level capabilities " + "sooner, and we're working to refine them", + "send feedback with /feedback or learn more", + ): + self.assertIn(needle, flat, + f"indexable transcription missing: {needle!r}") + + def test_readme_spells_out_what_it_never_does_to_a_paused_run(self): + flat = _norm(self.read()) + for needle in ("does not continue a paused run", + "does not edit or retry a flagged prompt", + "does not switch models", + "a real pause is recorded and the run stops"): + self.assertIn(needle, flat, + f"no-bypass wording missing: {needle!r}") + + def test_readme_keeps_a_plain_privacy_warning(self): + self.assertIn("never put secrets", _norm(self.read())) + + def test_readme_links_quickstart_and_reference(self): + text = self.read() + self.assertIn("docs/QUICKSTART.md", text) + self.assertIn("docs/REFERENCE.md", text) + + def test_readme_keeps_natural_discovery_phrases(self): + text = self.read() + for phrase in ("Claude Code", "Fable 5", "Session paused", + "bounded context", "no automatic fallback", + "tmux", "JSONL", "model audit"): + self.assertIn(phrase, text, + f"discovery phrase missing: {phrase!r}") + + def test_screenshot_bytes_are_preserved_exactly(self): + self.assertTrue(SCREENSHOT.is_file(), f"{SCREENSHOT_RELPATH} missing") + digest = hashlib.sha256(SCREENSHOT.read_bytes()).hexdigest() + self.assertEqual( + digest, SCREENSHOT_SHA256, + "the user-supplied screenshot was altered — its bytes must be " + "preserved exactly (no crop, recompress, or redraw)") + + def test_screenshot_is_tracked(self): + out = subprocess.run( + ["git", "ls-files", "--error-unmatch", SCREENSHOT_RELPATH], + cwd=REPO, capture_output=True, text=True, + ) + self.assertEqual(out.returncode, 0, + f"{SCREENSHOT_RELPATH} must be git-tracked") + + +LINK_RE = re.compile(r"\]\(([^)\s]+)\)") + + +class TestDocsRelativeLinksResolve(unittest.TestCase): + """Every relative markdown link in the user-facing docs must resolve + inside the repository (moving sections must not strand links).""" + + def assert_links_resolve(self, path): + text = path.read_text(encoding="utf-8") + for target in LINK_RE.findall(text): + if target.startswith(("http://", "https://", "#", "mailto:")): + continue + rel = target.split("#")[0] + resolved = (path.parent / rel).resolve() + self.assertTrue( + resolved.exists(), + f"{path.relative_to(REPO)}: broken relative link {target!r}") + self.assertIn(str(REPO), str(resolved), + f"{path.name}: link escapes the repo: {target!r}") + + def test_readme_links_resolve(self): + self.assert_links_resolve(REPO / "README.md") + + def test_quickstart_links_resolve(self): + self.assert_links_resolve(QUICKSTART) + + def test_reference_links_resolve(self): + self.assert_links_resolve(REFERENCE) + + +class TestReferenceDoc(unittest.TestCase): + """docs/REFERENCE.md carries the deep operator/agent material moved + out of the README, with every technical contract preserved: payload + privacy, budget pass-through, usage reporting, audit verdicts, watch + events, migration, development, and release notes.""" + + def read(self): + self.assertTrue(REFERENCE.is_file(), "docs/REFERENCE.md is missing") + return REFERENCE.read_text(encoding="utf-8") + + def test_reference_documents_the_payload_created_flag(self): + self.assertIn("payload_files_created", self.read()) + + def test_reference_documents_budget_pass_through(self): text = self.read() self.assertIn("max_budget_usd", text) self.assertIn("--max-budget-usd", text) - def test_readme_documents_usage_reporting(self): + def test_reference_documents_usage_reporting(self): text = self.read() self.assertIn("total_cost_usd", text) self.assertIn("modelUsage", text) - def test_readme_names_the_prerelease_tag(self): - text = self.read() - self.assertIn("0.3.0b1", text) - self.assertIn("v0.3.0-beta.1", text) - - def test_readme_privacy_contract_matches_the_payload_mechanism(self): + def test_reference_privacy_contract_matches_the_payload_mechanism(self): text = self.read() # The command-line visibility warning stays prominent and truthful: # the brief remains inspectable on the Claude process, the task @@ -359,7 +524,25 @@ def test_readme_privacy_contract_matches_the_payload_mechanism(self): # The old claim that payloads ride the tmux command line is gone. self.assertNotIn("deliberately deferred", text) - def test_readme_says_the_aliases_are_removed(self): + def test_reference_documents_no_fallback_enforcement(self): + text = self.read() + self.assertIn("switchModelsOnFlag", text) + self.assertIn("--fallback-model", text) + self.assertIn("--dangerously-skip-permissions", text) + + def test_reference_keeps_the_audit_verdicts(self): + text = self.read() + for verdict in ("PURE", "MIXED", "UNKNOWN"): + self.assertIn(verdict, text) + + def test_reference_keeps_the_watch_event_vocabulary(self): + text = self.read() + for event in ("refusal_pause", "model_fallback", "permission_block", + "completed_error", "completed_ambiguous", + "interrupted"): + self.assertIn(event, text) + + def test_reference_says_the_aliases_are_removed(self): text = self.read() self.assertIn("claude-context-run", text) # migration table stays self.assertNotIn("will be removed in the release after 0.2.0", text) @@ -367,10 +550,62 @@ def test_readme_says_the_aliases_are_removed(self): flat_text = " ".join(text.lower().split()) self.assertIn("removed in 0.3.0b1", flat_text) - def test_security_supports_the_0_3_line(self): - text = self.read("SECURITY.md") - self.assertIn("0.3.x", text) - self.assertNotIn("(0.2.x)", text) + +class TestQuickstartDoc(unittest.TestCase): + """docs/QUICKSTART.md walks one safe end-to-end beginner example: + checkout, registry, repo profile, task file, dry run, launch, one + watch, one audit — placeholders only, dry-run before launch, no + bypass flags, no budget cap, pinned Fable 5 high with fallback stop, + and the manifest flow exactly as the runner prints it.""" + + def read(self): + self.assertTrue(QUICKSTART.is_file(), "docs/QUICKSTART.md is missing") + return QUICKSTART.read_text(encoding="utf-8") + + def test_quickstart_covers_the_whole_lifecycle_in_order(self): + text = self.read() + positions = [text.find(marker) for marker in ( + "git clone", # checkout + "projects.toml", # registry + "agent-context/profiles", # repo profile + "# Task:", # task file + "--dry-run", # dry run first + "--launch", # explicit launch + "fable-session watch", # one watch + "fable-session audit", # one audit + )] + self.assertNotIn(-1, positions, + f"lifecycle step missing (positions: {positions})") + self.assertEqual(positions, sorted(positions), + "lifecycle steps must appear in beginner order") + + def test_quickstart_pins_fable_5_high_with_fallback_stop(self): + text = self.read() + self.assertIn('model = "claude-fable-5"', text) + self.assertIn('effort = "high"', text) + self.assertIn('fallback = "stop"', text) + + def test_quickstart_sets_no_budget_cap(self): + self.assertNotIn("max_budget_usd", self.read()) + + def test_quickstart_uses_the_manifest_flow(self): + text = self.read() + self.assertIn("manifest.json", text) + self.assertIn("--manifest", text) + + def test_quickstart_never_launches_claude_raw_or_bypasses(self): + text = self.read() + self.assertNotIn("dangerously", text) + self.assertNotRegex( + text, r"(?m)^\s*claude(\s|$)", + "the quickstart must never show a raw `claude` launch — the " + "runner builds the command") + + def test_quickstart_uses_safe_placeholders_and_no_credentials(self): + text = self.read() + self.assertIn("/srv/example-project", text) + for banned in ("ANTHROPIC_API_KEY=", "export ANTHROPIC"): + self.assertNotIn(banned, text) class TestSecurityPolicy(unittest.TestCase): From 6f801964c30b5d6435178a9e8768b8a7b0c9d1a0 Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 13:49:19 +0000 Subject: [PATCH 3/5] README: add evidence-backed why-try/why-trust, audience, limits; one --follow watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - explicit "Why try it?" / "Why trust it?" / "Who should try it" / "Current limits" sections, backed only by verified evidence: 500+ automated tests behind protected-main CI, stdlib-only runtime, tracked-tree and full-history readiness scans, offline wheel install smoke, SHA-pinned actions, and the one real Linux / Claude Code 2.1.208 smoke (stdin task delivery, budget-fixture pass-through, bounded watch usage, exact marker, PURE actor audit with honest auxiliary-Haiku reporting, measured total cost) - docs/VALIDATION.md: compact, privacy-safe record of that smoke and the deterministic release gates, with explicit limitations and no claims beyond what actually ran - beginner lifecycle corrected to exactly one `watch --manifest ... --follow` that exits at the terminal state (README and Quickstart; audit after the watcher exits); the deep reference keeps every watch mode unchanged - readiness checker/tests now gate the proof/audience/limits wording, the validation link, and the one-watch --follow lifecycle via phrase needles — no snapshots, no exact line or test counts Co-Authored-By: Claude Fable 5 --- README.md | 89 ++++++++++++++++- docs/QUICKSTART.md | 27 +++--- docs/VALIDATION.md | 66 +++++++++++++ tests/public_readiness_check.py | 117 +++++++++++++++++++++- tests/test_public_readiness.py | 165 ++++++++++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 docs/VALIDATION.md diff --git a/README.md b/README.md index acba5b5..9b04cc6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,82 @@ structured event, and what happens next is your decision. The scope of this tool is stopping *accidental* pauses caused by oversized context, and making every run's outcome provable after the fact. +## Why try it? + +If you already run Claude Code by hand, each fable-session launch adds +four small, practical things: + +- **a smaller, repeatable task context** — one task file plus a + byte-budgeted repo profile build the brief, so the same inputs give + the same small context every time; +- **pinned Fable 5 with no automatic fallback** — the pinned model + either serves the session or the session stops; nothing silently + switches or retries; +- **one exact-lane watcher** — a single `watch --follow` on the run's + own manifest reports lifecycle events and exits by itself at the + terminal state; +- **a model and usage audit afterwards** — proof, from the session's + own JSONL evidence, of which models served it and what it cost. + +This removes *accidental* pause triggers; it does not guarantee that a +session is never paused — a real pause still stops the run. + +## Why trust it? + +Every claim here is verified, not promised: + +- **500+ automated tests** run behind protected-main GitHub CI; +- the runtime is **Python standard library only**, zero dependencies; +- public-readiness gates scan the tracked tree **and every reachable + Git blob** for internal identifiers and credential-shaped content; +- CI installs the wheel **offline** and dry-runs the installed CLI; +- every GitHub Action is **pinned to exact commit SHAs**; +- **one real smoke** on Linux with Claude Code 2.1.208 ran the whole + lifecycle: the task reached Claude on stdin, the launch accepted + `--max-budget-usd 0.5` (a smoke-test fixture only — a registry with + no budget key emits no budget flag), the `--follow` watcher emitted + a bounded usage summary and exited by itself, the session returned + the exact marker `FABLE_BETA1_SMOKE_OK`, and the audit reported + `PURE` for actor messages while honestly reporting auxiliary Haiku + use. Total measured cost: `$0.15400099999999997`. + +The compact, privacy-safe record of that smoke — including what it does +*not* prove — is [docs/VALIDATION.md](docs/VALIDATION.md). + +## Who should try it — and who should not + +A good fit if: + +- you run **important or long Claude Code sessions on Fable 5** and + want each one launched with a small, bounded, repeatable context; +- you are an **operator who needs reproducible evidence** — manifests, + lifecycle events, and exit codes you can script against; +- you drive sessions from an **orchestrator such as Hermes** and want + one watcher and one audit per lane. + +Not a fit: + +- it is **not a Claude replacement** — you still bring your own + installed, authenticated Claude Code CLI; +- it never helps **bypass safeguards**, and it never runs automatic + retries or swarms of sessions; +- for a **casual one-off chat**, registering a project and writing a + task file is more ceremony than the chat is worth. + +## Current limits + +The honest edges of this public beta: + +- **Linux with Claude Code 2.1.208** is the only verified combination; + other platforms and CLI versions are unverified; +- **Python 3.12+ and tmux are required**, and setup is manual — you + write the registry, profile, and task files yourself; +- task and brief payload files are **readable by processes of your + same UID**, and the brief stays visible on the Claude **process + command line** — never put secrets in a task file; +- this is a **public beta**, not a fleet scheduler: one session, one + watcher, one audit at a time. + ## Prerequisites - **Linux** — the tested real-host combination is Linux with @@ -127,17 +203,18 @@ One console command, three subcommands — the whole lifecycle: ```bash fable-session run --project example-api --task /abs/task.md --launch --tmux api-example -fable-session watch --manifest /abs/runs//manifest.json --once +fable-session watch --manifest /abs/runs//manifest.json --follow fable-session audit --manifest /abs/runs//manifest.json ``` - `fable-session run` — resolve a registered project, build the bounded brief, construct the exact Claude CLI command, and (only with `--launch`) start it in a **new** tmux session; -- `fable-session watch` — monitor exactly one launched session's - structured JSONL stream and report lifecycle events (pauses and - refusals included) without ever acting on the session; -- `fable-session audit` — the post-run model audit: prove from the +- `fable-session watch` — follow exactly one launched session's + structured JSONL stream, reporting lifecycle events (pauses and + refusals included) without ever acting on the session, and exit by + itself at the lane's terminal state; +- `fable-session audit` — after the watcher exits: prove from the session's own transcript which models actually served it. Every option, guarantee, and edge case is specified in the @@ -161,6 +238,8 @@ are documented in the configuration, run state, audit verdicts, watchdog events, privacy contract, migration from claude-context-tools, development, and release notes; +- [Validation record](docs/VALIDATION.md) — the privacy-safe evidence + for the one real smoke and the deterministic release gates; - [Security policy](SECURITY.md) — how to report a vulnerability (GitHub private vulnerability reporting only); - [examples/projects.toml](examples/projects.toml) — a commented diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 0f1f72a..426d24e 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -126,27 +126,32 @@ The session starts inside a **new** tmux session named two commands take exactly that path. (A launch gets a fresh random run id, so it is not the same path your dry run printed.) -## 7. Watch it (one scan) +## 7. Watch it (one watcher, exits by itself) -Point the watchdog at the launch manifest for a single scan: +Point the watchdog at the launch manifest with `--follow`. One watcher +per session is the whole lifecycle: it polls only this lane's transcript +and exits on its own when the lane reaches a terminal state — you never +re-run it: ```bash fable-session watch \ --manifest "$HOME/.local/state/fable-session/runs//manifest.json" \ - --once + --follow ``` -Silence means nothing new happened. Each confirmed lifecycle event is one -compact JSON line — `completed` when the session finished, or -`refusal_pause` if Fable 5's safeguards genuinely paused it. A real pause -is recorded and the run stops: fable-session does not continue a paused -run, does not edit or retry a flagged prompt, and does not switch models. -What happens next in that tmux window is your decision as a human. +Silence means nothing new has happened yet. Each confirmed lifecycle +event is one compact JSON line — `completed` (with a bounded usage +summary) when the session finished, or `refusal_pause` if Fable 5's +safeguards genuinely paused it. A real pause is recorded and the run +stops: fable-session does not continue a paused run, does not edit or +retry a flagged prompt, and does not switch models. What happens next in +that tmux window is your decision as a human. ## 8. Audit it -After the session finishes, prove which model actually served it — from -the session's own JSONL evidence, using the same manifest: +After the watcher exits, the session has reached a terminal state — now +prove which model actually served it, from the session's own JSONL +evidence, using the same manifest: ```bash fable-session audit \ diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..158d140 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,66 @@ +# Validation record: fable-session 0.3.0b1 + +This is the compact, privacy-safe evidence behind the README's +"Why trust it?" section: what was actually verified for 0.3.0b1, how, +and — just as important — what it does not show. It deliberately +contains no hostnames, usernames, filesystem paths, session ids, +credentials, prompt text, or transcript text. + +## Deterministic release gates + +These run offline on every change, locally and in protected-main GitHub +CI, and must all pass: + +- `python3 -m compileall` over sources and tests; +- the full unit test suite (500+ tests, standard-library `unittest`; + no network, no real tmux sessions, no Claude API); +- the public-readiness scan of the tracked tree (naming, notices, + internal identifiers, credential-shaped literals); +- the full-history readiness scan — the same policy applied to every + tracked blob of every commit reachable from HEAD; +- whitespace hygiene checked against the empty tree; +- an offline install smoke: build the wheel, install it into a + disposable venv with no index access, and dry-run the installed CLI + from outside the checkout; +- every GitHub Action pinned to exact commit SHAs, with pinned Python + tooling versions. + +## The one real smoke + +- **Recorded:** 2026-07-14 +- **Version:** fable-session 0.3.0b1 +- **Environment:** a Linux host with Claude Code CLI 2.1.208, + Python 3.12, and tmux — the verified combination named in the README. +- **Scope:** one bounded session end to end — `run --launch`, one + `watch --follow`, one `audit` — driving a minimal marker task. + +Observed results: + +- the task was delivered to Claude on **stdin** from a hash-verified, + run-scoped payload file; it appeared on no process command line; +- the launch accepted and passed through **`--max-budget-usd 0.5`** — + chosen purely as a cheap smoke-test fixture, not a normal default (a + registry with no budget key emits no budget flag at all); +- the `--follow` watcher stayed silent while the session ran, emitted + one terminal `completed` event carrying the bounded usage aggregate, + and exited by itself; +- the session returned the exact expected marker `FABLE_BETA1_SMOKE_OK`; +- the model audit reported **`PURE`** for actor messages — every + observed assistant message was served by `claude-fable-5` — while the + usage aggregate honestly showed auxiliary Haiku activity (helper-model + use is reported and does not by itself break purity); +- total measured cost of the whole session: **`$0.15400099999999997`**. + +## Limitations — what this record does not show + +- It is **one smoke on one host with one CLI version** (Linux, Claude + Code 2.1.208). No other platform or CLI version is verified, and one + run is not a performance benchmark, an adoption signal, or a + production-maturity claim. +- It does not show that pauses are avoided: bounding context removes + accidental oversized-context triggers, but fable-session can never + guarantee a session is not paused, and a genuine pause still stops + the run. +- **No release artifact or attestation exists yet.** Checksums and + provenance attestation are produced only by a successful run of the + tag workflow for `v0.3.0-beta.1`, which has not run. diff --git a/tests/public_readiness_check.py b/tests/public_readiness_check.py index 35e2b2a..e915452 100644 --- a/tests/public_readiness_check.py +++ b/tests/public_readiness_check.py @@ -52,7 +52,16 @@ screenshot at docs/assets/fable-5-session-paused.jpg with its visible message transcribed as indexable plain text, the no-bypass wording, and working links to docs/QUICKSTART.md and docs/REFERENCE.md (both - must exist). + must exist); +- verifies the evidence-first README contract: explicit Why try it? / + Why trust it? / Who should try it / Current limits sections, backed + only by verified evidence (the deterministic gates plus the one real + Linux / Claude Code 2.1.208 smoke), honest audience and limits + wording, a link to the privacy-safe docs/VALIDATION.md record (which + must exist and carry the smoke evidence), no marketing superlatives, + and a beginner lifecycle of exactly one ``watch --follow`` that exits + at the terminal state (never repeated ``--once`` scans) in README and + Quickstart. Exit codes: 0 ready (current tree only — git HISTORY is explicitly not covered and must not be pushed publicly; see PUBLIC_RELEASE_CHECKLIST.md), @@ -470,6 +479,9 @@ def check_notices(findings: list[str]) -> None: if not (REPO / path).is_file(): findings.append(f"{path} missing") check_beginner_readme(findings, text, plain) + check_evidence_readme(findings, text, plain) + check_lifecycle_docs(findings, text) + check_validation_record(findings) def check_beginner_readme(findings: list[str], text: str, @@ -510,6 +522,109 @@ def check_beginner_readme(findings: list[str], text: str, findings.append(f"{path} missing") +def check_evidence_readme(findings: list[str], text: str, + plain: str) -> None: + """Evidence-first README contract: explicit why-try / why-trust / + audience / limits sections backed only by verified evidence, a link + to the privacy-safe validation record, and no marketing claims. + Needles are short phrases, never snapshots or exact counts.""" + flat = " ".join(plain.split()) + for heading in ("## Why try it?", "## Why trust it?", + "## Who should try it", "## Current limits"): + if heading not in text: + findings.append( + f"README.md: evidence section heading missing ({heading!r})") + # Why try it? — the practical gain, without promising pause avoidance. + for needle in ("repeatable", "pinned", "no automatic fallback", + "exact-lane", "does not guarantee"): + if needle not in flat: + findings.append( + f"README.md: why-try wording missing ({needle!r})") + # Why trust it? — only the verified evidence. + for needle in ("500+", "protected", "standard library", + "every reachable", "offline", "commit shas", + "--max-budget-usd 0.5", "smoke-test fixture", + "$0.15400099999999997", "haiku", "claude code 2.1.208"): + if needle not in flat: + findings.append( + f"README.md: verified-evidence wording missing ({needle!r})") + if "FABLE_BETA1_SMOKE_OK" not in text: + findings.append( + "README.md: the exact smoke marker FABLE_BETA1_SMOKE_OK missing") + if "PURE" not in text: + findings.append("README.md: the audited PURE verdict is missing") + # Audience: who it is for, who it is not for. + for needle in ("orchestrator", "hermes", "not a claude replacement", + "one-off chat"): + if needle not in flat: + findings.append( + f"README.md: audience wording missing ({needle!r})") + # Current limits: honest edges of the public beta. + for needle in ("public beta", "fleet scheduler", "same uid", + "process command line", "manual"): + if needle not in flat: + findings.append( + f"README.md: current-limits wording missing ({needle!r})") + if "docs/VALIDATION.md" not in text: + findings.append("README.md: link to docs/VALIDATION.md missing") + for banned in ("production-ready", "production ready"): + if banned in flat: + findings.append( + f"README.md: unverified marketing claim ({banned!r})") + if re.search(r"\bbest\b", plain): + findings.append("README.md: superlative 'best' is banned") + + +def check_lifecycle_docs(findings: list[str], readme_text: str) -> None: + """Beginner lifecycle: exactly one ``watch --follow`` that exits at + the lane's terminal state — never repeated ``--once`` scans — in the + README and the Quickstart. The deep reference still documents every + watch mode and is out of scope here.""" + docs = [("README.md", readme_text)] + quickstart = REPO / "docs" / "QUICKSTART.md" + if quickstart.is_file(): + docs.append(("docs/QUICKSTART.md", + quickstart.read_text(encoding="utf-8"))) + for name, text in docs: + if "--follow" not in text: + findings.append( + f"{name}: the beginner watch lifecycle must use one " + "`watch --manifest ... --follow`") + if "--once" in text: + findings.append( + f"{name}: beginner docs must not teach repeated --once " + "scans — one --follow watcher exits at the terminal state") + if "terminal state" not in _flat(text): + findings.append( + f"{name}: must say the watcher exits at the terminal state") + + +def check_validation_record(findings: list[str]) -> None: + """docs/VALIDATION.md: the compact, privacy-safe evidence record for + the one real smoke and the deterministic release gates — with the + smoke facts, the budget-fixture honesty, and explicit limitations.""" + path = REPO / "docs" / "VALIDATION.md" + if not path.is_file(): + findings.append("docs/VALIDATION.md missing") + return + text = path.read_text(encoding="utf-8") + flat = _flat(text) + for needle in ("0.3.0b1", "2.1.208", "FABLE_BETA1_SMOKE_OK", "PURE", + "$0.15400099999999997", "--max-budget-usd 0.5"): + if needle not in text: + findings.append( + f"docs/VALIDATION.md: evidence field missing ({needle!r})") + for needle in ("limitations", "smoke-test fixture", "haiku", "stdin", + "one smoke"): + if needle not in flat: + findings.append( + f"docs/VALIDATION.md: required wording missing ({needle!r})") + for banned in ("production-ready", "production ready"): + if banned in flat: + findings.append( + f"docs/VALIDATION.md: unverified claim ({banned!r})") + + def main() -> int: findings: list[str] = [] try: diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index 2862877..110f942 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -457,6 +457,171 @@ def test_screenshot_is_tracked(self): f"{SCREENSHOT_RELPATH} must be git-tracked") +VALIDATION = REPO / "docs" / "VALIDATION.md" + +# The verified evidence of the one real smoke (Linux, Claude Code +# 2.1.208): exact marker, exact measured cost, and the budget value that +# was only a smoke-test fixture. Fixed facts, quoted verbatim. +SMOKE_MARKER = "FABLE_BETA1_SMOKE_OK" +SMOKE_COST = "$0.15400099999999997" +SMOKE_BUDGET_FLAG = "--max-budget-usd 0.5" + + +class TestEvidenceFirstReadme(unittest.TestCase): + """The README answers why try / why trust / who it is for / what is + still limited — with verified evidence only, a link to the validation + record, and no marketing superlatives. Phrase needles only: no + snapshots, no exact line or test counts.""" + + def read(self): + return (REPO / "README.md").read_text(encoding="utf-8") + + def test_readme_has_the_four_evidence_sections(self): + text = self.read() + for heading in ("## Why try it?", "## Why trust it?", + "## Who should try it", "## Current limits"): + self.assertIn(heading, text, f"heading missing: {heading!r}") + + def test_why_try_names_the_practical_gains_without_promises(self): + flat = _norm(self.read()) + for needle in ("repeatable", "pinned", "no automatic fallback", + "exact-lane", "model", "audit"): + self.assertIn(needle, flat, f"why-try gain missing: {needle!r}") + # Never a promise of pause avoidance. + self.assertIn("does not guarantee", flat) + + def test_why_trust_uses_only_the_verified_evidence(self): + text = self.read() + flat = _norm(text) + for needle in ("500+", "protected", "standard library", + "every reachable", "offline", "commit shas", + "claude code 2.1.208", "stdin", "haiku"): + self.assertIn(needle, flat, f"evidence missing: {needle!r}") + # The one real smoke, quoted exactly. + self.assertIn(SMOKE_MARKER, text) + self.assertIn("PURE", text) + self.assertIn(SMOKE_COST, text) + # The budget was a smoke-test fixture, never a normal default. + self.assertIn(SMOKE_BUDGET_FLAG, text) + self.assertIn("smoke-test fixture", flat) + + def test_audience_covers_fit_and_non_fit(self): + flat = _norm(self.read()) + for needle in ("orchestrator", "hermes", + "not a claude replacement", "one-off chat"): + self.assertIn(needle, flat, f"audience wording missing: {needle!r}") + + def test_current_limits_are_honest(self): + flat = _norm(self.read()) + for needle in ("public beta", "fleet scheduler", "same uid", + "process command line", "manual", + "claude code 2.1.208"): + self.assertIn(needle, flat, f"limit missing: {needle!r}") + + def test_readme_links_the_validation_record(self): + self.assertIn("docs/VALIDATION.md", self.read()) + + def test_readme_makes_no_marketing_claims(self): + plain = self.read().replace("**", "").lower() + flat = " ".join(plain.split()) + for banned in ("production-ready", "production ready", + "testimonial", "battle-tested", "blazing"): + self.assertNotIn(banned, flat, f"marketing claim: {banned!r}") + self.assertNotRegex(plain, r"\bbest\b", + "superlative 'best' is banned") + + +class TestOneWatchFollowLifecycle(unittest.TestCase): + """Beginner docs teach exactly one `watch --manifest ... --follow` + that exits at the lane's terminal state — never repeated `--once` + scans. The deep reference still documents every watch mode.""" + + def docs(self): + return (("README.md", (REPO / "README.md").read_text("utf-8")), + ("docs/QUICKSTART.md", QUICKSTART.read_text("utf-8"))) + + def test_beginner_docs_watch_with_follow_not_once(self): + for name, text in self.docs(): + with self.subTest(doc=name): + self.assertIn("--follow", text, + f"{name}: beginner watch must use --follow") + self.assertNotIn("--once", text, + f"{name}: no repeated --once scans") + self.assertIn("terminal state", _norm(text), + f"{name}: watcher must exit at terminal state") + + def test_quickstart_audits_after_the_watcher_exits(self): + text = QUICKSTART.read_text("utf-8") + self.assertLess(text.find("--follow"), + text.find("fable-session audit"), + "audit must come after the one --follow watch") + self.assertIn("watcher exits", _norm(text)) + + def test_reference_still_documents_every_watch_mode(self): + text = REFERENCE.read_text("utf-8") + self.assertIn("--once", text) + self.assertIn("--follow", text) + + +class TestValidationRecord(unittest.TestCase): + """docs/VALIDATION.md is the compact, privacy-safe evidence record: + date, version, scope, results, and explicit limitations — never + hostnames, usernames, absolute paths, session ids, credentials, + prompts, or transcript text (the tree-wide scans enforce the leak + side; these needles enforce the evidence side).""" + + def read(self): + self.assertTrue(VALIDATION.is_file(), "docs/VALIDATION.md missing") + return VALIDATION.read_text(encoding="utf-8") + + def test_record_carries_the_exact_smoke_evidence(self): + text = self.read() + for needle in ("0.3.0b1", "2.1.208", SMOKE_MARKER, "PURE", + SMOKE_COST, SMOKE_BUDGET_FLAG, "Linux"): + self.assertIn(needle, text, f"evidence missing: {needle!r}") + flat = _norm(text) + for needle in ("stdin", "haiku", "smoke-test fixture", "one smoke", + "--follow"): + self.assertIn(needle, flat, f"wording missing: {needle!r}") + + def test_record_names_the_deterministic_gates(self): + flat = _norm(self.read()) + for needle in ("unit test", "tracked tree", "every tracked blob", + "offline", "commit shas", "whitespace"): + self.assertIn(needle, flat, f"gate missing: {needle!r}") + + def test_record_states_date_and_explicit_limitations(self): + text = self.read() + self.assertRegex(text, r"\b2026-\d{2}-\d{2}\b", + "the record must carry an ISO date") + flat = _norm(text) + self.assertIn("limitations", flat) + for needle in ("one smoke on one host", "not", "guarantee"): + self.assertIn(needle, flat) + + def test_record_never_overclaims(self): + flat = _norm(self.read()) + for banned in ("production-ready", "production ready", + "battle-tested"): + self.assertNotIn(banned, flat, f"overclaim: {banned!r}") + # Broader claims are allowed only as explicit negations + # ("not a performance benchmark", "not an adoption signal"). + for word in ("benchmark", "adoption"): + if word in flat: + self.assertIn("not a", flat) + self.assertNotIn(f"proven {word}", flat) + + def test_record_is_tracked_and_linked_from_the_readme(self): + out = subprocess.run( + ["git", "ls-files", "--error-unmatch", "docs/VALIDATION.md"], + cwd=REPO, capture_output=True, text=True, + ) + self.assertEqual(out.returncode, 0, + "docs/VALIDATION.md must be git-tracked") + readme = (REPO / "README.md").read_text(encoding="utf-8") + self.assertIn("docs/VALIDATION.md", readme) + + LINK_RE = re.compile(r"\]\(([^)\s]+)\)") From 586b15923fc018b6499f15e7e290c8aabbae79ee Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 14:02:24 +0000 Subject: [PATCH 4/5] docs: temper pause-reduction claim --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b04cc6..2bce735 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ four small, practical things: - **a model and usage audit afterwards** — proof, from the session's own JSONL evidence, of which models served it and what it cost. -This removes *accidental* pause triggers; it does not guarantee that a +This can reduce avoidable pause triggers; it does not guarantee that a session is never paused — a real pause still stops the run. ## Why trust it? From f72c4fd76fc0e02e34e3d10bcee977bbeeb4352d Mon Sep 17 00:00:00 2001 From: Vesper Date: Tue, 14 Jul 2026 14:17:14 +0000 Subject: [PATCH 5/5] test: use exact path containment for docs links --- tests/test_public_readiness.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index 110f942..e693008 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -639,8 +639,9 @@ def assert_links_resolve(self, path): self.assertTrue( resolved.exists(), f"{path.relative_to(REPO)}: broken relative link {target!r}") - self.assertIn(str(REPO), str(resolved), - f"{path.name}: link escapes the repo: {target!r}") + self.assertTrue( + resolved.is_relative_to(REPO), + f"{path.name}: link escapes the repo: {target!r}") def test_readme_links_resolve(self): self.assert_links_resolve(REPO / "README.md")