diff --git a/PUBLIC_RELEASE_CHECKLIST.md b/PUBLIC_RELEASE_CHECKLIST.md index 64ee50f..2a48b9d 100644 --- a/PUBLIC_RELEASE_CHECKLIST.md +++ b/PUBLIC_RELEASE_CHECKLIST.md @@ -1,12 +1,17 @@ # Public release checklist -**Status: the source is PUBLIC.** The official repository at -`https://github.com/getaskclaw/fable-session` is public, and every +**Status: the source is PUBLIC and the `v0.3.0-beta.1` prerelease is +out.** The official repository at +`https://github.com/getaskclaw/fable-session` is public, 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. +candidate), and the `v0.3.0-beta.1` prerelease tag was pushed after the +release-status PR (PR #2) merged and protected-main CI passed on merge +commit `181d3215`. The GitHub prerelease at +https://github.com/getaskclaw/fable-session/releases/tag/v0.3.0-beta.1 +points at that merge commit and carries the wheel, sdist, `SHA256SUMS`, +and source archives. Package-index publication stays out of scope for +this prerelease. ## Resolved @@ -42,6 +47,17 @@ PR merges and protected-main CI passes on the merge commit. - [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. +- [x] **Prerelease tag and GitHub prerelease: RESOLVED.** The + `v0.3.0-beta.1` prerelease tag (distribution version `0.3.0b1`) + was pushed only after the release-status PR merged and + protected-main CI passed on merge commit `181d3215`. `release.yml` + enforced the canonical tag fail-closed via + `tests/release_tag_check.py`, re-ran the release gates, built the + artifacts, generated `SHA256SUMS`, attested build provenance, and + created the GitHub prerelease at + https://github.com/getaskclaw/fable-session/releases/tag/v0.3.0-beta.1 + with the wheel, sdist, `SHA256SUMS`, and source archives. + Package-index publication stays out of scope for this prerelease. ## Standing warning — the OLD history @@ -76,17 +92,3 @@ re-runs the same gates on every pull request, this status PR included: `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. - -## 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 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 2bce735..5afba59 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ # 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. +fable-session 是一个套在 Claude Code CLI +外面的本地 Python 工具:每个 session 启动之前, +先替它备好一份小而固定的任务上下文。 +上下文短了、边界清楚了,日常写代码就不那么容易误触暂停。 +它不是模型,也不是 Anthropic 的产品。 -> **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. +> **非官方项目,与 Anthropic 无关。** 这是一个独立的社区工具, +> 用来运行 Claude Code CLI。它不是 Anthropic 出品, +> 也没有得到 Anthropic 的认可或支持。 -> **This tool does not bypass safeguards.** It never retries, rewrites, or -> re-submits flagged content, never switches models to dodge a refusal, and -> never makes unsafe work permissible. If a session is paused or refused, -> fable-session reports that truthfully and stops. +> **这个工具不绕过任何安全机制。** 它不会重试、改写或重新提交被标记的内容, +> 也不靠换模型躲开拒绝;不该跑的东西,它也不会替你想办法跑起来。 +> session 被暂停或拒绝时,fable-session 如实报告,然后停下。 -## 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 @@ -31,141 +31,135 @@ Send feedback with /feedback or learn more 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 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. - -## 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 - -> **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 -> same-named package appearing later would be someone else's). Until -> 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, install from a -> checkout as shown in the [Quickstart](docs/QUICKSTART.md). - -Install into an isolated environment from the tag-pinned source: +先把丑话说在前面:真的被暂停之后,fable-session +不会继续已暂停的运行,不会修改或重试被标记的 prompt, +不会切换模型(截图里的选项 1 和 2 是留给你本人的,它绝不代劳), +更不会绕过安全机制。真实的暂停会被记录,运行就停在那里, +接下来怎么办由你决定。 + +如果你用 Fable 5 在 Claude Code 里做日常开发也总撞上这个暂停, +最常见、也最容易避免的原因,是上下文太大又没有边界: +一个巨长的 prompt、整段贴进来的日志, +或者老代码堆里埋着的某个敏感词。 +fable-session 做的事,是在启动前把送进 session 的内容收拢好; +session 一旦被标记,它绝不再碰。 + +## 它做什么 + +- **有边界的上下文**:简报由一个任务文件加一份仓库里的 profile 拼成, + 有硬性的字节上限,而不是把手边的东西一股脑塞进去; +- **锁定模型,不自动 fallback**:要么用你指定的模型跑完这个 session, + 要么直接停下;不会悄悄降级、切换或重试; +- **事后能查**:audit 读的是 session 自己的结构化 JSONL 流, + 核对这次实际用了哪些模型;watch 只报告一个 session 的状态, + 从头到尾不碰 session 本身。 + +## 它不做什么 + +session 真的被暂停或拒绝时,fable-session: + +- 不会继续已暂停的运行; +- 不会修改或重试被标记的 prompt; +- 不会切换模型(截图里的 1 和 2 留给你自己选,它从不代劳); +- 不会以任何方式削弱或绕过安全机制。 + +真实的暂停会被记录,运行随即停止:暂停写成结构化事件,下一步由你决定。 +这个工具只管两件事:减少上下文过大造成的误伤暂停, +让每次运行的结果事后可查。 + +## 为什么值得一试 + +如果你已经在手动跑 Claude Code,fable-session +每次启动会多给你四样东西: + +- **小而固定的任务上下文**:同一个任务文件加同一份 profile, + 每次拼出同样的简报,不多不少; +- **锁定 Fable 5,不自动 fallback**:模型要么按你指定的来,要么停; +- **一个 watcher 盯一次运行**:对着这次运行自己的 manifest 跑一条 + `watch --follow`,报告运行事件,到终态自动退出; +- **跑完一份模型和用量 audit**:用 session 自己的 JSONL, + 看清这次实际用了哪些模型、花了多少钱。 + +这些能降低误触暂停的概率,但不保证 session 永远不被暂停——真的暂停仍然会让运行停止。 + +## 凭什么信它 + +这里的每一条都实际验证过,不是口头承诺: + +- **500+ 自动化测试**,跑在受保护的 main 分支 CI 上; +- 运行时只用 Python 标准库,零第三方依赖; +- 发布检查会扫描 git 跟踪的文件树,连同历史里每一个可达的 blob, + 查内部标识和长得像凭据的内容; +- CI 离线安装 wheel,再对装好的 CLI 做一次 dry run; +- 每个 GitHub Action 都钉在确切的 commit SHA 上; +- 在 Linux + Claude Code 2.1.208 上做过一次真实冒烟,从头跑到尾: + 任务经 stdin 送达 Claude,启动接受了 `--max-budget-usd 0.5` + (这只是冒烟测试的固定参数:registry 里不写预算键, + 就不会带任何预算 flag),`--follow` watcher + 输出了一段有上限的用量汇总并自己退出, + session 返回了精确标记 `FABLE_BETA1_SMOKE_OK`, + audit 对 actor 消息给出 `PURE`, + 同时如实报告了辅助的 Haiku 调用。 + 实测花费:`$0.15400099999999997`。 + +那次冒烟的简明记录——包括它证明不了什么——都在 +[docs/VALIDATION.md](docs/VALIDATION.md)。 + +## 适合谁,不适合谁 + +适合: + +- 你在 Fable 5 上跑重要或很长的 Claude Code session, + 希望每次启动都带一份小而固定的上下文; +- 你要拿运行记录写脚本:manifest、运行事件、退出码,一样不缺; +- 你用 Hermes 这类 orchestrator 驱动 session, + 想给每次运行配一个 watcher、跑一次 audit。 + +不适合: + +- 它不能替代 Claude:Claude Code CLI 要你自己安装、自己登录; +- 它不帮任何人绕过安全机制,也不做自动重试, + 更不会一口气拉起一群 session; +- 随手聊两句的事,犯不着先注册项目再写任务文件。 + +## 目前的限制 + +这个公开 beta 的实际边界: + +- 只在 **Linux + Claude Code 2.1.208** 这一个组合上验证过, + 其它平台和 CLI 版本都没验证; +- 需要 **Python 3.12+ 和 tmux**,而且配置全靠手动: + registry、profile、任务文件都要你自己写; +- 任务和简报的 payload 文件,同一 UID 的进程都读得到, + 简报还会一直留在 Claude 进程的命令行上—— + 千万不要把密钥写进任务文件; +- 这是公开 beta,不是集群调度器(fleet scheduler): + 一次一个 session、一个 watcher、一次 audit。 + +## 环境要求 + +- **Linux**:实测组合是 Linux + Claude Code 2.1.208, + 别的平台和 CLI 版本没验证过,这里也不做更多承诺; +- **Python 3.12+**:包本身零运行时依赖,只用标准库; +- **tmux**:启动的 session 会跑在新开的 tmux session 里; +- **Claude Code CLI**:要单独安装、单独登录, + 这个工具不附带它,也不替你配置。 + +## 安装 + +> **还没上 PyPI,装之前先看这段。** fable-session 0.3.0b1 +> 没有发布到 PyPI,这个名字目前在 PyPI 上也不属于本项目, +> 所以不要直接从包索引安装:今天什么都装不到, +> 以后如果冒出同名的包,那也是别人的东西。 +> 要不要发 PyPI,是以后另一个单独的决定。 +> 现在请只用下面钉住 tag 的 GitHub 源安装, +> 或者用 GitHub release 的附件。 +> `v0.3.0-beta.1` 的 tag 和 prerelease 已经发布: +> , +> 指向 commit `181d32155d81a3ff872a0e00cc4e19c6a3007c7d`, +> 附带 wheel、sdist、`SHA256SUMS` 和源码包。 + +装进一个隔离环境,源钉在 tag 上: ```bash # pipx (tag-pinned GitHub source) @@ -179,27 +173,25 @@ python3.12 -m venv ~/.venvs/fable-session ~/.venvs/fable-session/bin/pip install "fable-session @ git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" ``` -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. +也可以从上面的 release 页面下载 wheel 和 `SHA256SUMS`, +核对校验和之后,直接安装那个 wheel 文件。 -## Your first dry run (starts nothing) +## 第一次 dry run(什么都不启动) -A dry run is the default and launches nothing — it shows you exactly -what *would* run and writes a run manifest: +dry run 是默认行为,不会启动任何东西—— +它只展示将要运行的内容,并写出一份运行 manifest: ```bash fable-session run --project example-api --task /absolute/path/task.md --dry-run ``` -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. +在这之前,你要先注册一次项目,再往仓库里放一份小 profile。 +[Quickstart](docs/QUICKSTART.md) 会带你把第一个 session 走完: +安装、registry、profile、任务文件、dry run、启动、一次 watch、一次 audit。 -## The three commands +## 三个子命令 -One console command, three subcommands — the whole lifecycle: +一个命令,三个子命令,一次运行从启动到审计都在里面: ```bash fable-session run --project example-api --task /abs/task.md --launch --tmux api-example @@ -207,54 +199,52 @@ fable-session watch --manifest /abs/runs//manifest.json --follow fable-session audit --manifest /abs/runs//manifest.json ``` -- `fable-session run` — resolve a registered project, build the bounded - brief, construct the exact Claude CLI command, and (only with - `--launch`) start it in a **new** tmux session; -- `fable-session watch` — 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` — 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`. +- `fable-session run`:解析注册过的项目,拼出有边界的简报, + 构造出确切的 Claude CLI 命令;只有加上 `--launch`, + 才会在一个**新的** tmux session 里启动它; +- `fable-session watch`:跟着一个已启动 session 的结构化 JSONL 流, + 报告运行事件(暂停和拒绝都在内),从不干预 session, + 到终态自动退出; +- `fable-session audit`:等 watcher 退出后, + 用 session 自己的记录,核对这次实际用了哪些模型。 + +选项、约定和各种边角情况,都写在 [Reference](docs/REFERENCE.md) 里。 + +## 隐私 + +运行 manifest 和命令输出里只有哈希、字节数和路径, +没有 prompt 文本、凭据,也没有终端历史。 +再说一遍:不要把密钥写进任务文件。 +session 运行期间,prompt 内容对同一主机上你自己用户的进程可读; +长得像密钥的内容会在启动前被直接拒绝。 +payload 的具体机制和它的边界, +见 [Reference](docs/REFERENCE.md#privacy-and-metadata)。 + +## 文档 + +- [Quickstart](docs/QUICKSTART.md):第一个 session 从头到尾, + checkout、registry、profile、任务文件、dry run、启动、watch、audit; +- [Reference](docs/REFERENCE.md):全部细节,包括配置、运行状态、 + audit 结论、watch 事件、隐私约定、从 claude-context-tools 迁移、 + 开发与发布说明; +- [验证记录](docs/VALIDATION.md):那次真实冒烟和各项确定性发布检查的记录, + 不含隐私内容; +- [安全政策](SECURITY.md):怎么报告漏洞(只走 GitHub + private vulnerability reporting); +- [examples/projects.toml](examples/projects.toml):带注释的 registry 示例。 + +## 状态与许可证 + +fable-session 以 **MIT License** 发布,见 [LICENSE](LICENSE), +包元数据写的也是 MIT。项目主页是 +`https://github.com/getaskclaw/fable-session`:仓库已公开, +里面是独立审阅过的干净历史,main 分支受保护, +GitHub private vulnerability reporting 已开启并验证。 +`PUBLIC_RELEASE_CHECKLIST.md` 里的公开 beta 检查已全部完成; +`v0.3.0-beta.1` GitHub prerelease 已经发布,地址是 +, +指向 commit `181d32155d81a3ff872a0e00cc4e19c6a3007c7d`, +附带 wheel、sdist、`SHA256SUMS` 和源码包。 +发布到 PyPI 不在这次 prerelease 的范围内。 +原始的导出前工作历史,任何时候都不能推到公开仓库。 +安全问题见 `SECURITY.md`。 diff --git a/tests/public_readiness_check.py b/tests/public_readiness_check.py index e915452..e2632e1 100644 --- a/tests/public_readiness_check.py +++ b/tests/public_readiness_check.py @@ -43,18 +43,23 @@ 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; + scan gate, and a checked record of the shipped v0.3.0-beta.1 + prerelease (tag pushed, release workflow succeeded, GitHub prerelease + live at its releases/tag URL) with NO unchecked operational action + left — package-index publication stays out of scope; +- verifies the README notices and the guardrail files exist. The README + is the Simplified-Chinese landing page, so its prose needles are + Chinese; exact commands, flags, URLs, evidence strings, and the + verbatim English ``Session paused`` transcription stay untranslated; - 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`` + explanation (本地 Python 工具 for the Claude Code CLI — 不是模型, + 不是 Anthropic 的产品), 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 + message transcribed as indexable plain English text, the Chinese + no-bypass wording right after it, and working links to + docs/QUICKSTART.md and docs/REFERENCE.md (both must exist); +- verifies the evidence-first README contract: explicit why-try / + why-trust / audience / 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 @@ -81,6 +86,10 @@ # --- resolved license/URL decisions (fixed; drift is a finding) ------------- OFFICIAL_REPO_URL = "https://github.com/getaskclaw/fable-session" +# The live prerelease (tag pushed, release workflow succeeded): release +# docs must record it at exactly this URL and never regress to +# pending-tag wording. Package-index publication stays out of scope. +RELEASE_URL = OFFICIAL_REPO_URL + "/releases/tag/v0.3.0-beta.1" EXPECTED_PROJECT_URLS = { "Homepage": OFFICIAL_REPO_URL, "Repository": OFFICIAL_REPO_URL, @@ -327,9 +336,12 @@ def check_docs(findings: list[str]) -> None: """README/checklist/SECURITY must identify MIT and the official URL 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.""" + gates, record the shipped v0.3.0-beta.1 prerelease as a checked item + with no unchecked operational action left, and never reintroduce the + resolved license blocker, a BLOCKED status, or a stale + repository-is-uncreated/-private claim. The README states the + public/reporting/prerelease facts in its Chinese landing-page + wording.""" readme = REPO / "README.md" if readme.is_file(): text = readme.read_text(encoding="utf-8") @@ -342,15 +354,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): + if "仓库已公开" not in _flat(text): findings.append( "README.md: must state that the official repository is " - "public" + "public (仓库已公开)" ) - if "private vulnerability reporting enabled" not in _flat(text): + if "private vulnerability reporting 已开启" not in _flat(text): findings.append( "README.md: must state that GitHub private vulnerability " - "reporting is enabled on the official repository" + "reporting is enabled on the official repository " + "(private vulnerability reporting 已开启)" + ) + if RELEASE_URL not in text: + findings.append( + "README.md: must link the live v0.3.0-beta.1 GitHub " + f"prerelease at exactly {RELEASE_URL}" ) if "blocked" in text.lower(): findings.append( @@ -387,8 +405,13 @@ def check_docs(findings: list[str]) -> None: 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" + "— the source is public and the v0.3.0-beta.1 prerelease " + "shipped" + ) + if RELEASE_URL not in text: + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: must record the live " + f"v0.3.0-beta.1 GitHub prerelease at exactly {RELEASE_URL}" ) if "the source is public" not in _flat(text): findings.append( @@ -418,23 +441,22 @@ def check_docs(findings: list[str]) -> None: 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: stale unchecked " - f"{phrase!r} blocker — that gate is resolved; a " - "regression to the pre-flip state is untruthful" - ) - # 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)" + # The v0.3.0-beta.1 prerelease shipped (tag pushed, release + # workflow succeeded, GitHub prerelease live): it must stay + # recorded as a checked item, and no unchecked operational + # action may remain. + if unchecked: + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: no unchecked operational " + "action may remain — the v0.3.0-beta.1 prerelease " + f"shipped; stale unchecked items: {unchecked}" + ) + if not any("v0.3.0-beta.1" in item and "prerelease tag" in item + for item in checked): + findings.append( + "PUBLIC_RELEASE_CHECKLIST.md: no checked item records the " + "shipped v0.3.0-beta.1 prerelease tag and its GitHub " + "prerelease" ) if not any("official repository" in item and "reviewed clean root commit" in item @@ -470,10 +492,12 @@ def check_notices(findings: list[str]) -> None: return text = readme.read_text(encoding="utf-8") plain = text.replace("**", "").lower() - if "unofficial" not in plain or "not affiliated with anthropic" not in plain: - findings.append("README.md: unaffiliated notice missing") - if "does not bypass safeguards" not in plain: - findings.append("README.md: does-not-bypass-safeguards notice missing") + if "非官方" not in plain or "与 anthropic 无关" not in plain: + findings.append("README.md: unaffiliated notice missing " + "(非官方 / 与 Anthropic 无关)") + if "不绕过任何安全机制" not in plain: + findings.append("README.md: does-not-bypass-safeguards notice " + "missing (不绕过任何安全机制)") for path in ("SECURITY.md", "PUBLIC_RELEASE_CHECKLIST.md", ".github/workflows/ci.yml"): if not (REPO / path).is_file(): @@ -486,12 +510,13 @@ def check_notices(findings: list[str]) -> None: 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/.""" + """Beginner-first landing-page contract: plain-words Chinese + explanation, the preserved Session paused screenshot with its exact + English transcription kept indexable, explicit Chinese no-bypass + behavior, and links into docs/.""" flat = " ".join(plain.split()) - for needle in ("local python wrapper", "not a model", - "not an anthropic product"): + for needle in ("本地 python 工具", "不是模型", + "不是 anthropic 的产品"): if needle not in flat: findings.append( f"README.md: beginner explanation missing ({needle!r})") @@ -508,9 +533,9 @@ def check_beginner_readme(findings: list[str], text: str, 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"): + for needle in ("不会继续已暂停的运行", + "不会切换模型", + "真实的暂停会被记录"): if needle not in flat: findings.append( f"README.md: no-bypass behavior wording missing ({needle!r})") @@ -526,24 +551,25 @@ 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.""" + to the privacy-safe validation record, and no marketing claims in + either language. Prose needles are Chinese; evidence strings + (marker, cost, flags, versions) stay verbatim.""" flat = " ".join(plain.split()) - for heading in ("## Why try it?", "## Why trust it?", - "## Who should try it", "## Current limits"): + for heading in ("## 为什么值得一试", "## 凭什么信它", + "## 适合谁", "## 目前的限制"): 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"): + for needle in ("每次拼出同样的简报", "锁定", "不自动 fallback", + "自己的 manifest", "不保证"): 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", + for needle in ("500+", "受保护", "标准库", + "每一个可达的", "离线", "commit sha", + "--max-budget-usd 0.5", "冒烟测试的固定参数", "$0.15400099999999997", "haiku", "claude code 2.1.208"): if needle not in flat: findings.append( @@ -554,20 +580,22 @@ def check_evidence_readme(findings: list[str], text: str, 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"): + for needle in ("orchestrator", "hermes", "不能替代 claude", + "随手聊两句"): 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"): + for needle in ("公开 beta", "fleet scheduler", "同一 uid", + "进程的命令行", "配置全靠手动"): 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"): + for banned in ("production-ready", "production ready", + "赋能", "无缝", "重磅", "最佳实践", + "完整生命周期", "可复现证据"): if banned in flat: findings.append( f"README.md: unverified marketing claim ({banned!r})") @@ -580,12 +608,13 @@ def check_lifecycle_docs(findings: list[str], readme_text: str) -> None: 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)] + 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: + quickstart.read_text(encoding="utf-8"), + "terminal state")) + for name, text, terminal in docs: if "--follow" not in text: findings.append( f"{name}: the beginner watch lifecycle must use one " @@ -594,9 +623,10 @@ def check_lifecycle_docs(findings: list[str], readme_text: str) -> None: 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): + if terminal not in _flat(text): findings.append( - f"{name}: must say the watcher exits at the terminal state") + f"{name}: must say the watcher exits at the terminal " + f"state ({terminal!r})") def check_validation_record(findings: list[str]) -> None: diff --git a/tests/test_mit_release.py b/tests/test_mit_release.py index 7f3765d..9de0386 100644 --- a/tests/test_mit_release.py +++ b/tests/test_mit_release.py @@ -8,15 +8,18 @@ - `pyproject.toml` metadata says MIT (setuptools-68-compatible `license = { text = "MIT" }`) and carries the exact official project URLs at https://github.com/getaskclaw/fable-session; -- README, PUBLIC_RELEASE_CHECKLIST.md, and SECURITY.md truthfully identify +- README (the Simplified-Chinese landing page), + PUBLIC_RELEASE_CHECKLIST.md, and SECURITY.md truthfully identify MIT, the official repository URL, and GitHub private vulnerability 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); + must neither claim it is still uncreated or private, nor pin a + transient momentary state (empty / commitless / zero-ref claims). The + `v0.3.0-beta.1` tag workflow succeeded: README and checklist must + record the live GitHub prerelease at its exact releases/tag URL, with + no stale pending-tag wording and package-index publication still out + of scope; - 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 @@ -95,6 +98,10 @@ RELEASE_DOCS = ("README.md", "PUBLIC_RELEASE_CHECKLIST.md", "SECURITY.md") +# The live prerelease: tag pushed, release workflow succeeded, GitHub +# prerelease created at exactly this URL. +RELEASE_URL = OFFICIAL_URL + "/releases/tag/v0.3.0-beta.1" + def flat(text): """Lowercase with all whitespace (including wraps) collapsed.""" @@ -254,26 +261,35 @@ def test_security_md_names_the_official_repo_and_private_reporting(self): "SECURITY.md must not invent an email/contact address", ) - 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.) + def test_docs_say_public_and_record_the_live_prerelease(self): + # The repository IS public and the v0.3.0-beta.1 tag workflow + # succeeded: README (in Chinese) and the checklist must say so + # and point at the exact releases/tag URL. + readme_text = (REPO / "README.md").read_text(encoding="utf-8") + self.assertIn("仓库已公开", flat(readme_text)) + self.assertIn(RELEASE_URL, readme_text) + checklist_text = ( + REPO / "PUBLIC_RELEASE_CHECKLIST.md").read_text(encoding="utf-8") + self.assertIn("the source is public", flat(checklist_text)) + self.assertIn(RELEASE_URL, checklist_text) + # No stale pending-tag wording may survive the release. Composed + # so this file never contains the claims literally. + stale_pending = ( + "does not exist " + "yet", + "prerelease tag " + "is pending", + "sole remaining operational " + "action", + "until that workflow " + "succeeds", + ) 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"): + for claim in stale_pending: self.assertNotIn( claim, lowered, - f"{name} must not claim the prerelease is live") + f"{name} still carries stale pending-tag wording") + # PyPI stays out of scope: the docs may only mention it to say + # the package is NOT there. + self.assertIn("没有发布到 PyPI", readme_text) + self.assertIn("out of scope", flat(checklist_text)) def test_docs_do_not_claim_the_repo_is_private(self): # Post-flip: any claim that the official repository is (still) @@ -372,16 +388,25 @@ def test_checklist_resolves_visibility_reporting_and_protection(self): f"the export blocker is resolved; unchecked items: {unchecked}", ) - def test_checklist_sole_unchecked_item_is_the_prerelease_tag(self): - unchecked = [flat(item) for item in checklist_items() + def test_checklist_records_the_shipped_prerelease_with_nothing_open(self): + items = checklist_items() + unchecked = [flat(item) for item in 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}", + unchecked, [], + "the v0.3.0-beta.1 prerelease shipped — no unchecked " + f"operational action may remain; unchecked items: {unchecked}", + ) + checked = [flat(item) for item in items + if item.startswith("- [x]")] + self.assertTrue( + any("v0.3.0-beta.1" in item and "prerelease tag" in item + and "releases/tag/v0.3.0-beta.1" in item + for item in checked), + "a checked item must record the shipped v0.3.0-beta.1 " + "prerelease tag with its GitHub release URL; " + f"checked items: {checked}", ) - 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: @@ -604,14 +629,31 @@ def test_checklist_unchecking_the_reporting_record_fails(self): self.doCleanups() self.assert_finding(findings, "vulnerability reporting") - def test_checklist_checking_off_the_pending_tag_fails(self): + def test_checklist_unchecking_the_prerelease_record_fails(self): + # The prerelease shipped; regressing its record to an unchecked + # blocker would be untruthful. 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")) + b"- [x] **Prerelease tag and GitHub prerelease", + b"- [ ] **Prerelease tag and GitHub prerelease")) findings = self.scan() self.doCleanups() self.assert_finding(findings, "v0.3.0-beta.1") + def test_checklist_dropping_the_prerelease_record_fails(self): + self.mutate("PUBLIC_RELEASE_CHECKLIST.md", lambda raw: raw.replace( + b"Prerelease tag", b"Tag").replace( + b"prerelease tag", b"tag")) + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, "prerelease tag") + + def test_readme_losing_the_release_url_fails(self): + self.mutate("README.md", lambda raw: raw.replace( + b"/releases/tag/v0.3.0-beta.1", b"/releases")) + findings = self.scan() + self.doCleanups() + self.assert_finding(findings, "README.md: must link") + def test_docs_claiming_the_repo_is_private_fails(self): stale = ("\nNote: the official repository " + "is private.\n").encode() diff --git a/tests/test_public_readiness.py b/tests/test_public_readiness.py index e693008..ef60d93 100644 --- a/tests/test_public_readiness.py +++ b/tests/test_public_readiness.py @@ -264,13 +264,13 @@ def test_readme_leads_with_the_new_name(self): self.assertTrue(self.read().lstrip().startswith("# fable-session")) def test_unaffiliated_notice_is_obvious(self): + # The landing page is Simplified Chinese; the notice is too. text = self.read() - self.assertIn("Unofficial", text) - self.assertIn("not affiliated with Anthropic", text) + self.assertIn("非官方", text) + self.assertIn("与 Anthropic 无关", text) def test_does_not_bypass_safeguards_is_obvious(self): - self.assertIn("does not bypass safeguards", self.read().lower() - .replace("**", "")) + self.assertIn("不绕过任何安全机制", self.read().replace("**", "")) def test_examples_use_only_the_canonical_cli(self): # New docs use `fable-session run|audit|watch`; the legacy names may @@ -292,7 +292,7 @@ def read(self, name="README.md"): def test_readme_has_an_installation_section(self): text = self.read() - self.assertIn("## Install", text) + self.assertIn("## 安装", text) # Pre-index beta: every install form is tag-pinned to the reviewed # prerelease tag — never a bare index name. pinned = "git+https://github.com/getaskclaw/fable-session@v0.3.0-beta.1" @@ -316,7 +316,7 @@ def test_no_doc_recommends_unqualified_index_installs(self): def test_readme_pre_index_warning_precedes_every_install_command(self): text = self.read() - warning = text.find("not published on PyPI") + warning = text.find("没有发布到 PyPI") self.assertNotEqual(warning, -1, "the pre-index warning is missing") installs = [idx for idx in (text.find("pipx install"), text.find("uv tool install"), @@ -326,10 +326,18 @@ def test_readme_pre_index_warning_precedes_every_install_command(self): self.assertLess(warning, min(installs), "the pre-index warning must come before every " "install command") - # Honest availability claim: tag-pinned installs work only once the - # public tag exists. - self.assertIn("only after", text) - self.assertIn("tag exists", text) + + def test_readme_links_the_live_prerelease(self): + # The v0.3.0-beta.1 tag and GitHub prerelease exist: the README + # must point at the exact releases/tag URL so the tag-pinned + # install commands are verifiably live. + text = self.read() + release_url = ("https://github.com/getaskclaw/fable-session" + "/releases/tag/v0.3.0-beta.1") + self.assertIn(release_url, text) + self.assertLess(text.find(release_url), text.find("pipx install"), + "the live-prerelease pointer belongs with the " + "install warning, before the commands") def test_readme_lists_prerequisites_and_the_tested_combination(self): text = self.read() @@ -379,21 +387,21 @@ def test_readme_stays_a_short_landing_page(self): def test_readme_opens_with_a_one_sentence_beginner_explanation(self): text = _norm(self.read()) - for needle in ("local python wrapper", + for needle in ("本地 python 工具", "claude code", - "not a model", - "not an anthropic product"): + "不是模型", + "不是 anthropic 的产品"): 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("你见过这个画面吗", text) self.assertIn(SCREENSHOT_RELPATH, text) - # "Near the top": before the Install section, not buried below it. + # "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") + text.find("## 安装"), + "screenshot must appear before the install section") def test_readme_transcribes_the_screenshot_as_indexable_text(self): text = self.read() @@ -417,15 +425,29 @@ def test_readme_transcribes_the_screenshot_as_indexable_text(self): 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"): + for needle in ("不会继续已暂停的运行", + "不会修改或重试被标记的 prompt", + "不会切换模型", + "真实的暂停会被记录"): self.assertIn(needle, flat, f"no-bypass wording missing: {needle!r}") + def test_readme_explains_the_pause_in_chinese_right_after_the_shot(self): + # The English transcription stays verbatim for search; the very + # next prose must say, in Chinese, what fable-session never does + # after a real pause. + text = self.read() + transcription_end = text.find("2. Edit prompt and retry with Fable 5") + self.assertNotEqual(transcription_end, -1) + explanation = text.find("不会继续已暂停的运行") + self.assertNotEqual(explanation, -1) + self.assertLess(transcription_end, explanation) + self.assertLess(explanation - transcription_end, 400, + "the Chinese no-bypass explanation must follow " + "the transcription immediately") + def test_readme_keeps_a_plain_privacy_warning(self): - self.assertIn("never put secrets", _norm(self.read())) + self.assertIn("不要把密钥写进任务文件", _norm(self.read())) def test_readme_links_quickstart_and_reference(self): text = self.read() @@ -433,10 +455,12 @@ def test_readme_links_quickstart_and_reference(self): self.assertIn("docs/REFERENCE.md", text) def test_readme_keeps_natural_discovery_phrases(self): + # What a stuck user would actually search for — English terms + # stay English, the concepts are findable in Chinese. text = self.read() for phrase in ("Claude Code", "Fable 5", "Session paused", - "bounded context", "no automatic fallback", - "tmux", "JSONL", "model audit"): + "有边界的上下文", "不自动 fallback", + "tmux", "JSONL", "audit", "manifest"): self.assertIn(phrase, text, f"discovery phrase missing: {phrase!r}") @@ -478,23 +502,23 @@ def read(self): 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"): + for heading in ("## 为什么值得一试", "## 凭什么信它", + "## 适合谁", "## 目前的限制"): 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"): + for needle in ("每次拼出同样的简报", "锁定", "不自动 fallback", + "自己的 manifest", "模型", "audit"): self.assertIn(needle, flat, f"why-try gain missing: {needle!r}") # Never a promise of pause avoidance. - self.assertIn("does not guarantee", flat) + self.assertIn("不保证", 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", + for needle in ("500+", "受保护", "标准库", + "每一个可达的", "离线", "commit sha", "claude code 2.1.208", "stdin", "haiku"): self.assertIn(needle, flat, f"evidence missing: {needle!r}") # The one real smoke, quoted exactly. @@ -503,18 +527,18 @@ def test_why_trust_uses_only_the_verified_evidence(self): 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) + self.assertIn("冒烟测试的固定参数", 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"): + "不能替代 claude", "随手聊两句"): 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", + for needle in ("公开 beta", "fleet scheduler", "同一 uid", + "进程的命令行", "配置全靠手动", "claude code 2.1.208"): self.assertIn(needle, flat, f"limit missing: {needle!r}") @@ -530,6 +554,16 @@ def test_readme_makes_no_marketing_claims(self): self.assertNotRegex(plain, r"\bbest\b", "superlative 'best' is banned") + def test_readme_avoids_translated_marketing_chinese(self): + # The Chinese must read like a developer wrote it: no marketing + # words, no translation-tone boilerplate. + flat = _norm(self.read()) + for banned in ("赋能", "无缝", "重磅", "最佳实践", "一站式", + "完整生命周期", "可复现证据", "它想解决的问题", + "操作者", "技术 beta"): + self.assertNotIn(banned, flat, + f"translated-marketing wording: {banned!r}") + class TestOneWatchFollowLifecycle(unittest.TestCase): """Beginner docs teach exactly one `watch --manifest ... --follow` @@ -537,17 +571,21 @@ class TestOneWatchFollowLifecycle(unittest.TestCase): 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"))) + # (name, text, terminal-state needle) — the README says it in + # Chinese, the Quickstart still in English. + return (("README.md", (REPO / "README.md").read_text("utf-8"), + "终态"), + ("docs/QUICKSTART.md", QUICKSTART.read_text("utf-8"), + "terminal state")) def test_beginner_docs_watch_with_follow_not_once(self): - for name, text in self.docs(): + for name, text, terminal 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), + self.assertIn(terminal, _norm(text), f"{name}: watcher must exit at terminal state") def test_quickstart_audits_after_the_watcher_exits(self):