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..2bce735 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,65 +14,147 @@ > 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 + +When a session is genuinely paused or refused, fable-session: + +- 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. + +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. + +## 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 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? + +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 + **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 -Prerequisites: - -- **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). - -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. - > **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 > NOT run an unqualified index install (nothing qualifies today, and a @@ -75,8 +162,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,561 +183,78 @@ 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. - -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`): - -```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. +## Your first dry run (starts nothing) -## 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 +A dry run is the default and launches nothing — it shows you exactly +what *would* run and writes a run manifest: ```bash -python3 -m compileall -q src tests -python3 -m unittest discover -s tests -v +fable-session run --project example-api --task /absolute/path/task.md --dry-run ``` -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 -``` +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. -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 three commands -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: +One console command, three subcommands — the whole lifecycle: ```bash -python3 tests/public_readiness_check.py -python3 tests/full_history_readiness_check.py +fable-session run --project example-api --task /abs/task.md --launch --tmux api-example +fable-session watch --manifest /abs/runs//manifest.json --follow +fable-session audit --manifest /abs/runs//manifest.json ``` -## 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 once 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` — 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 +[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; +- [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 + registry example. ## Status and license 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`. - -## 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). +`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`. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..426d24e --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,167 @@ +# 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 watcher, exits by itself) + +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" \ + --follow +``` + +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 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 \ + --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/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/docs/assets/fable-5-session-paused.jpg b/docs/assets/fable-5-session-paused.jpg new file mode 100644 index 0000000..f13440d Binary files /dev/null and b/docs/assets/fable-5-session-paused.jpg differ diff --git a/tests/public_readiness_check.py b/tests/public_readiness_check.py index 59cb620..e915452 100644 --- a/tests/public_readiness_check.py +++ b/tests/public_readiness_check.py @@ -32,18 +32,36 @@ 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); -- verifies the README notices and the guardrail files exist. + 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; +- verifies the beginner-first README contract: the plain-words + explanation (local Python wrapper for the Claude Code CLI — not a + model, not an Anthropic product), the preserved ``Session paused`` + 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); +- 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), @@ -83,7 +101,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 +113,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 +306,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 +325,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 +342,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 +384,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: 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 BLOCKED release status " - "is gone" + "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 +407,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): - findings.append( - "PUBLIC_RELEASE_CHECKLIST.md: stale unchecked export " - "blocker — the clean-history export is resolved (one " - "reviewed clean root commit)" + # 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: 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" ) @@ -408,6 +478,151 @@ def check_notices(findings: list[str]) -> None: ".github/workflows/ci.yml"): 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, + 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 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: 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..e693008 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -10,13 +10,16 @@ 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 references and must never be pushed publicly (PUBLIC_RELEASE_CHECKLIST.md). """ +import hashlib import importlib.util import re import shutil @@ -28,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 @@ -269,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") @@ -286,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() @@ -313,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) @@ -328,22 +342,341 @@ 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") + + +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]+)\)") + + +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.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") + + 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 @@ -357,7 +690,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) @@ -365,10 +716,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): @@ -384,21 +787,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