--force # 已有安装先备份成 *.backup-<时间戳> 再替换
+node bin/distilly.mjs install --path # 装到自定义路径(末段目录必须叫 distilly)
+node bin/distilly.mjs --help
+```
+
+- **宿主 id、全局/项目级目录、确切安装命令、双语注意事项、装完怎么验证**:见
+ **[docs/v2/HOSTS.md](docs/v2/HOSTS.md)**。该表由 `src/hosts/agents.mjs` 生成,`tests/agents.test.mjs`
+ 强制它与 `bin/distilly.mjs` 的落盘目录一致。
+- 当前支持 8 个宿主:`claude-code` · `codex` · `opencode` · `openclaw` · `hermes` · `deepseek-harness` ·
+ `grok-build` · `pi`。别名:`claude`、`deepseek`、`grok`。
+- 两条路线等价:`npx -y skills add titanwings/distilly --skill distilly …`(AgentSkills CLI)或直接
+ `git clone https://github.com/titanwings/distilly <目标目录>`;逐字命令同样在 `docs/v2/HOSTS.md`。
+
+
+
+### ⚠️ Deprecated:`python3 tools/*.py` 安装器
+
+下面「选择你的平台」各节里的 `python3 tools/install_*_skill.py` 与手工 `git clone` 是**迁移期兼容路径,已废弃**:
+v2 不再要求用户手动跑 Python。契约(`docs/v2/CONTRACT.md` §1)约定旧的 `python3 tools/xxx.py` 调用由
+`bin/distilly.mjs` 转发并打印 deprecation 警告,转发层在 PR③ 删除;在当前集成分支上这些命令仍然等价于
+直接执行对应的 Python 脚本。旧内容只为排查老安装而保留,**新安装请走 `bin/distilly.mjs` 或
+`docs/v2/HOSTS.md` 里的一行命令**。
+
+---
+
## 选择你的平台
+> ⚠️ **Deprecated(旧安装路径)**:本节保留旧版按平台展开的说明。宿主目录与确切命令的最新版本在
+> **[docs/v2/HOSTS.md](docs/v2/HOSTS.md)**;下面的 `python3 tools/*.py` 调用见上一节的废弃说明。
+
### A. Claude Code(推荐)
本项目遵循官方 [AgentSkills](https://agentskills.io) 标准,整个 repo 就是 skill 目录。克隆到 Claude skills 目录即可:
@@ -533,3 +569,4 @@ distilly/ ← clone 到宿主的 skills/distilly/(例如 .claude
├── versions/ # 历史版本
└── knowledge/ # 原始材料归档
```
+
diff --git a/INSTALL_EN.md b/INSTALL_EN.md
index b916f542..fb91f756 100644
--- a/INSTALL_EN.md
+++ b/INSTALL_EN.md
@@ -3,8 +3,53 @@
> Distilly was formerly known as **Colleague Skill / colleague-skill**. The creator
> name and canonical install directory are now `distilly`.
+
+
+## v2: command entrypoint and host adaptation
+
+v2 has exactly one command entrypoint: **`bin/distilly.mjs`** (Node >= 20, zero
+dependencies, see `docs/v2/CONTRACT.md`).
+
+```bash
+node bin/distilly.mjs install # install into that host's global Skill directory
+node bin/distilly.mjs install --force # back up an existing install as *.backup-, then replace
+node bin/distilly.mjs install --path # install into a custom path (final directory must be `distilly`)
+node bin/distilly.mjs --help
+```
+
+- **Host ids, global/project directories, the exact install commands, bilingual
+ notes and how to verify an install** live in
+ **[docs/v2/HOSTS.md](docs/v2/HOSTS.md)**. That table is generated from
+ `src/hosts/agents.mjs`, and `tests/agents.test.mjs` forces it to agree with the
+ destinations in `bin/distilly.mjs`.
+- Eight hosts are supported today: `claude-code`, `codex`, `opencode`,
+ `openclaw`, `hermes`, `deepseek-harness`, `grok-build`, `pi`. Aliases:
+ `claude`, `deepseek`, `grok`.
+- The two routes are equivalent — `npx -y skills add titanwings/distilly
+ --skill distilly …` (AgentSkills CLI) or a plain
+ `git clone https://github.com/titanwings/distilly `; both are quoted
+ verbatim in `docs/v2/HOSTS.md`.
+
+
+
+### ⚠️ Deprecated: the `python3 tools/*.py` installers
+
+The `python3 tools/install_*_skill.py` calls and manual clones below are
+**migration-era compatibility paths and are deprecated**: v2 no longer asks users
+to run Python by hand. The contract (`docs/v2/CONTRACT.md` §1) says the entrypoint
+forwards the old `python3 tools/xxx.py` calls with a deprecation warning and that
+the forwarding layer is removed in PR③; on the current integration branch those
+commands are still equivalent to running the Python script directly. The old
+sections are kept for troubleshooting legacy installs only — **use
+`bin/distilly.mjs` or the one-liners in `docs/v2/HOSTS.md` for new installs**.
+
## Install Distilly
+> ⚠️ **Deprecated (legacy install path)**: this section keeps the old per-host
+> walkthrough. The current host directories and exact commands are in
+> **[docs/v2/HOSTS.md](docs/v2/HOSTS.md)**; the `python3 tools/*.py` calls are
+> explained in the deprecation note above.
+
Clone the repository into a Skills directory discovered by your host, keeping
the destination directory name `distilly`:
@@ -157,3 +202,4 @@ keep only copyright-safe paraphrases with source URLs in research notes, and
delete the temporary file after review.
Xquik is independent of X Corp. “Twitter” and “X” are trademarks of X Corp.
+
diff --git a/README.md b/README.md
index b529f9c3..64333e04 100644
--- a/README.md
+++ b/README.md
@@ -4,336 +4,188 @@
-# 🧬 Distilly
+# Distilly
-**Formerly: Colleague Skill / colleague-skill.**
+### Distill how they think into Person Profiles for Agents.
-### Distill a person's experience, judgment, voice, and ways of working into a reusable Person Profile for AI agents and compatible bots.
-
-**Messages · documents · interviews · public sources → Distilly → Person Profile → Agent / Bot**
+**Colleague Skill / colleague-skill (original name)**
[](LICENSE)
-[](https://python.org)
-[](https://agentskills.io)
-[](https://github.com/titanwings/distilly/stargazers)
-
-[](https://discord.gg/NVX66RxWZv)
-
-
-
-
-
-
-🧑💼 Your colleague quit, your mentor graduated, your teammate transferred — taking their whole playbook and context with them?
-💞 Your family, old friends, partner drifting apart — and you want to hold on to the way it felt to be with them?
-🌟 Your favorite author, idol, thinker you'll never meet — but you want to know what they'd say about your question?
-
-
-
-
-### ✨ One project, many kinds of people.
-
-
-
-Distilly is the person-modeling layer for agents. It turns the materials you provide into a portable, source-grounded Person Profile built from observable experience, decision patterns, expression, and ways of working; it does not claim to clone the person behind them.
-
-Colleagues · partners · family · old friends · idols · public figures · fictional characters — even yourself
-
-**Source material + your description → a source-grounded Person Profile → your Agent or compatible Bot**
-
-> A Person Profile is the reusable output. The current release packages each profile as an Agent Skill so supported hosts can install and invoke it. The canonical creator Skill is named `distilly`; install it in a `distilly` directory. The former name above remains for search continuity and project history.
-
-
-
-[🆕 What Distilly does](#-what-distilly-does-today) · [📦 Data Sources](#-supported-data-sources) · [⚡ Install](#-install) · [🚀 Usage](#-usage) · [✨ Demo](#-demo) · [📝 Citation](#-citation) · [💬 Discord](https://discord.gg/NVX66RxWZv)
-
-[**Chinese**](docs/lang/README_ZH.md) · [**Spanish**](docs/lang/README_ES.md) · [**German**](docs/lang/README_DE.md) · [**Japanese**](docs/lang/README_JA.md) · [**Russian**](docs/lang/README_RU.md) · [**Portuguese**](docs/lang/README_PT.md) · [**Korean**](docs/lang/README_KO.md)
-
-
-
----
-
-
-
-### 🎉 2026.08.13 Milestone — **the project has passed 20K ⭐!**
-
-Massive thanks to everyone who starred — we'll keep shipping, keep distilling.
-
-
-
-> 🧬 **2026.08.24 Update** — The creator is now named **Distilly** end to end and documents native local Skill discovery for Claude Code, Hermes, OpenClaw, Codex, DeepSeek Harness, Pi, Grok Build, and OpenCode. Grok Bot is listed separately as a saved-Skill workflow preview.
-
-> 📝 **2026.06.01 Update** — **[The COLLEAGUE.SKILL technical report](https://arxiv.org/pdf/2605.31264) is now available**. The most rewarding part was not simply publishing a paper, but seeing the community grow the gallery to 215 skills contributed by 165 people, with more than 100,000 stars across the skill cards. The paper's Acknowledgements explicitly recognize every community contributor.
-
-> 🗺️ **2026.04.13** — **The Distilly Roadmap is live!** What began as Colleague Skill is growing beyond colleagues: distill people into Skills that Agents can reuse. 👉 **[Full Roadmap](ROADMAP.md)** · **[💬 Discord](https://discord.gg/NVX66RxWZv)**
-
-> 🌐 **2026.04.07** — Community gallery is live! Any skill / meta-skill can drive traffic directly to your own GitHub repo. No middleman. 👉 **[titanwings.github.io/colleague-skill-site](https://titanwings.github.io/colleague-skill-site/)**
-
-
-
-Created by [@titanwings](https://github.com/titanwings)
+[](https://nodejs.org/)
+[](https://github.com/titanwings/distilly/tree/distilly-plugin)
----
-
-## 🆕 What Distilly does today
-
-### 1️⃣ From Colleague Skill to Distilly
-
-The project is no longer limited to the colleague scenario. Its `distilly` creator builds source-grounded Person Profiles for three person families with one workflow, then packages each profile as an Agent Skill.
-
-### 2️⃣ Three character families
-
-
-
-
-🧑💼 colleague
-💞 relationship
-🌟 celebrity
-
-
-
-
-Coworkers · mentors · teammates · up/downstream partners
-Exes · partners · parents · friends · close family
-Public figures · creators · public voices · fictional characters
-
-
-Builds a Work Skill + Persona from material-derived technical standards, workflows, expression, and workplace behavior. Supports Lark / DingTalk / Slack collection.
-Organizes material-derived expression patterns, emotional triggers, conflict patterns, and repair patterns into a reusable Persona Skill.
-Ships with a six-dimension research toolchain (subtitles → transcript cleanup → research merge → quality check) for organizing observable decisions, expression, and mental models.
-
-
-
-
-Each family has its own source-collection strategy, analysis dimensions, and Person Profile structure.
-
-### 3️⃣ More Agent hosts
-
-The old version only ran in Claude Code. Distilly now supports native local Skill discovery across eight agent hosts.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-**Grok Bot preview:** Grok Bot supports saved/private Skills, but its official docs do not describe direct local `SKILL.md` imports. Distilly's workflow can be migrated manually into a saved Skill; direct repo installation is not yet verified.
-
-Each generated Person Profile is packaged as an Agent Skill and can be installed into any supported host.
+Distilly is a local-first product for turning a person's source material, working habits, judgment, and voice into a versioned **Person Profile for Agents**. The profile can be recalled temporarily during a run or explicitly installed as a long-lived host Skill. The storage authority stays local; no additional model API key is required.
----
+## 这一支(`dot-skill-test`):人物 Skill + 证据脊柱
-## 📦 Supported Data Sources
+**把一个人的原材料蒸馏成一个可调用的人物 Skill,外加一份每条结论都能回指到原文的画像页。**
+零运行时依赖,只要 Node ≥ 20。
-| Logo | Source | Messages | Docs / Wiki | Notes |
-|:----:|--------|:--------:|:-----------:|-------|
-| | Lark (auto) | ✅ API | ✅ | Just enter a name, fully automatic |
-| | DingTalk (auto) | ⚠️ Browser | ✅ | DingTalk API doesn't support message history |
-| | Slack (auto) | ✅ API | — | Requires admin to install Bot; free plan limited to 90 days |
-| | Public X posts | ✅ API | — | Optional, bounded celebrity research candidates through metered third-party service Xquik |
-| | WeChat chat history | ✅ SQLite | — | Export first with WeChatMsg or PyWxDump |
-| 📄 | PDF / Images / Screenshots | — | ✅ | Manual upload |
-| | Lark JSON export | ✅ | ✅ | Manual upload |
-| ✉️ | Email `.eml` / `.mbox` | ✅ | — | Manual upload |
-| 📝 | Markdown / direct paste | ✅ | ✅ | Manual input |
-
----
+| 交付物 | 路径 |
+| --- | --- |
+| 人物 Skill(可直接装进宿主运行) | `skills///SKILL.md` + `work.md` `persona.md` `work_skill.md` `persona_skill.md` `manifest.json` `meta.json` |
+| 画像页(单文件、离线、双主题) | `views/.html` + `evidence/renders/receipt.json` |
-## ⚡ Install
+三个 family:`colleague` / `relationship` / `celebrity`。
+八个宿主:Claude Code · Codex · opencode · OpenClaw · Hermes · **DeepSeek Harness** · Grok Build · Pi。
-### 🤖 For Agents
+**这份实现与那条 Plugin 路线的区别,一句话**:它不追求"像不像",它保证"凭什么这么说"——
+每条结论都能回指到原文的字节区间(`[k00NN]` 锚点),派生可复跑(同一输入两次字节相同),
+交付物由机械门禁压住(验收 17 项,含"产物齐 / Layer 0–5 齐 / 悬空锚点=0")。
-Open any supported local Agent host and send:
+### 装到一个宿主(以 DeepSeek Harness 为例)
-> Install Distilly from `https://github.com/titanwings/distilly`, then verify that this host can discover it.
-
-The Agent installs Distilly as a Skill named `distilly` in the correct host directory.
+```bash
+git clone https://github.com/titanwings/distilly.git && cd distilly
+git checkout dot-skill-test
+node bin/distilly.mjs install deepseek-harness # → $DSH_HOME/skills/distilly
+```
-### 👤 For Humans
+装完即可被 DSH 发现(技能目录被 watch,无需重启),输入 `/distilly` 或直接让 Agent 开始蒸馏。
+其它宿主把 `deepseek-harness` 换成 `claude-code` / `codex` / `opencode` / `openclaw` / `hermes` /
+`grok-build` / `pi`;每条宿主的确切路径与命令见 [`docs/v2/HOSTS.md`](docs/v2/HOSTS.md)。
-Clone Distilly into the Skills directory used by your host:
+### 怎么验
```bash
-git clone https://github.com/titanwings/distilly
+npm test # 391 项,Node 20 与 22 各一遍
+node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-interview --person lin-gong # 17/17
+DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> node scripts/audit-objective.mjs # 15/15
```
-Host paths, migration, Windows, generated-profile installation, and credential setup are in the **[Install Guide](INSTALL_EN.md)**.
+**当前状态、已知缺口、分支与 PR 清单**:[`docs/v2/STATUS.md`](docs/v2/STATUS.md)。
+契约 [`docs/v2/CONTRACT.md`](docs/v2/CONTRACT.md) · 验收 [`docs/v2/ACCEPTANCE.md`](docs/v2/ACCEPTANCE.md)。
---
-## 🚀 Usage
-
-In your Agent, say:
-
-> Use Distilly to create a Person Profile for ``.
+## 另一条产品线:`distilly-plugin`(Plugin Developer Preview)
-Then:
+> **下面这一节以及其后的「Install the Developer Preview / Host compatibility / The first usable
+> flow / Host status / Local material formats」各节,描述的不是本分支的代码**,而是
+> `distilly-plugin` 分支上的 Plugin(MCP / Panel / SQLite)路线。本分支不构建、也不运行它们;
+> 保留在这里只是不让那条线的信息丢失。
-1. Choose `colleague`, `relationship`, or `celebrity`.
-2. Add a description or source material. Every field is optional.
-3. Review the result and let Distilly generate the profile.
+This `distilly-plugin` branch carries the unreleased `0.1.0-preview.1` Developer Preview; the repository's default branch is `dot-skill`, the separate legacy implementation, so a bare clone lands on that line instead of this one. Codex, OpenClaw `2026.3.24`, and Hermes `v0.9.0` each have an immutable real-host transport-capacity fixture. The OpenClaw and Hermes measurements use a deterministic synthetic fixture server through the real host executable, model, and MCP transport; they do not by themselves certify packaged restart or the full product lifecycle. Setup remains fail-closed for any unrecorded host version or changed release tuple. This branch is not a tagged release or an npm package yet.
-The result is packaged as an Agent Skill named `{character}-{slug}`. For the optional celebrity research pipeline and advanced tools, see the **[Install Guide](INSTALL_EN.md)**.
+[Chinese](docs/lang/README_ZH.md) · [Español](docs/lang/README_ES.md) · [Deutsch](docs/lang/README_DE.md) · [日本語](docs/lang/README_JA.md) · [한국어](docs/lang/README_KO.md) · [Português](docs/lang/README_PT.md) · [Русский](docs/lang/README_RU.md)
----
-
-## ✨ Demo
-
-One from each family.
-
-
+## Install the Developer Preview
-### 🌟 celebrity — distilling Andrej Karpathy
+### For an agent
-> Six-dimension research: works · interviews · decisions · expression DNA · external evaluations · timeline
-> Case adapted from community work [alchaincyf/karpathy-skill](https://github.com/alchaincyf/karpathy-skill)
+Give your coding agent the following task and let it run the commands in a fresh checkout:
-```
-User ❯ Everyone's talking about Agents now — what do you think the 2026 AI Agent looks like?
-
-celebrity.skill ❯ Everyone's doing demos, nobody's doing eval.
- The Agent bottleneck was never model intelligence — it's scaffolding:
- a strict evaluation harness, fine-grained error replay,
- controllable tool-calling budgets, clean failure-mode analysis.
- Most products are still stuck at "wrap an API + a few tools + a system prompt."
- That's a demo, not an agent.
- In 2026 I'm not betting on the strongest model — I'm betting on the best
- scaffolding and the strictest eval.
-```
+> Install the Distilly Developer Preview from the `distilly-plugin` branch, build it with Node 22.19+ (or Node 24), run `distilly setup --host codex`, run `distilly doctor --host codex`, and report the result. Do not modify another branch.
-
+The exact checkout and setup commands are shown below so the agent can verify every step.
-### 🧑💼 colleague — distilling a ByteDance L2-1 backend
+### For a human
-> Input: `ByteDance L2-1 backend engineer, INTJ, blame-shifter, ByteDance-style`
+Requirements: Node.js `22.19+` or `24`, pnpm `10.32+`, and a locally installed Codex CLI. From a terminal:
+```bash
+git clone --branch distilly-plugin https://github.com/titanwings/distilly.git
+cd distilly
+corepack enable
+pnpm install --frozen-lockfile
+pnpm run build
+node packages/cli/lib/bin.js setup --host codex
+node packages/cli/lib/bin.js doctor --host codex
```
-User ❯ Can you review this API design?
-
-colleague.skill ❯ Hold on — what's the impact? You haven't explained the context.
- (after reading) N+1 query, fix it. Use the standard
- {code, message, data} response format. That's the spec,
- don't ask why.
-User ❯ This bug was introduced by you, right?
+Restart Codex after setup. The launcher registers the self-contained Plugin and its five MCP tools. To remove the host integration while keeping all local people, profiles, and source data:
-colleague.skill ❯ Does the timeline match? That feature touched multiple places,
- there were other changes too.
+```bash
+node packages/cli/lib/bin.js uninstall --host codex
```
-
-
-### 💞 relationship — distilling someone you have a crush on
+To install one approved profile as a persistent Skill after a profile has been created, use its exact subject id:
-> Upload half a year of chat logs + "sensitive, quiet but stubborn, will actually reply seriously when it matters"
-
-```
-User ❯ Did you think about me today?
-
-relationship.skill ❯ ...I did, a little bit. Why are you asking?
+```bash
+node packages/cli/lib/bin.js install subject_<32 lowercase hex characters> --host codex
```
-
-
-📚 More real-world cases in the **[community gallery](https://titanwings.github.io/colleague-skill-site/)** — 100+ skills and counting
-
-
-
----
+## Host compatibility and explicit Legacy fallback
-## 🔧 Features
+Codex uses the native Plugin preview above. The Preview also includes compatibility bindings for OpenClaw and Hermes:
-### 🧱 Generated Skill Structure
+- **OpenClaw** loads the Claude-compatible bundle from `~/.openclaw/extensions/distilly` and its real `.mcp.json`. Check discovery with `openclaw plugins inspect distilly --json`.
+- **Hermes** installs the canonical Skill at `~/.hermes/skills/distilly`, a managed wrapper at `~/.distilly/bin/distilly-hermes`, and an MCP entry in `~/.hermes/config.yaml`. `resources` and `prompts` are disabled so the exposed surface remains five tools; check it with `hermes mcp test distilly`.
-Distilly's current creator uses **Persona** as the universal base, with family-specific modules layered on top:
+The CLI recognizes both hosts and enables setup when the installed version matches the recorded real-host transport fixture. The current net budgets, measured in isolated clean sessions with `openai-codex/gpt-5.4`, are 65,536 serialized bytes for OpenClaw and 49,752 for Hermes (the same conservative byte/token accounting used by the Codex fixture). These are transport/value lower bounds for the recorded probe, not a guarantee of remaining context in every model or user session. Any unrecorded version, release digest, tool descriptor, or serializer tuple returns `host_unsupported` before writing an unverified integration. There is no automatic switch to the legacy implementation.
-| Family | Persona Content | Additional Modules |
-|--------|-----------------|-------------------|
-| 🧑💼 **colleague** | 6-layer personality: hard rules → identity → expression → decisions → interpersonal → Correction | ➕ **Work Skill**: scope, workflow, output preferences, experience knowledge base |
-| 💞 **relationship** | Expression DNA · emotional triggers · conflict pattern · repair pattern | — |
-| 🌟 **celebrity** | Mental models · decision heuristics · expression DNA · external-evaluation contrast | ➕ Six-dimension research dossier (works / interviews / decisions / timeline...) |
+Until a host has a verified Plugin binding, you can explicitly choose the maintained `dot-skill` branch as a **Legacy Skill compatibility mode**:
-> **Execution**: Receive task → Persona selects material-derived preferences and tone → Additional modules fill in execution detail → Produce a source-grounded response
+> Install Distilly in Legacy Skill compatibility mode from the `dot-skill` branch into this host's normal Skills directory, using a clean checkout whose final directory is named `distilly`. Verify discovery and report the installed Git commit. Do not run Plugin setup or claim SQLite, five-tool MCP, Panel, or Plugin lifecycle support.
-### 🧬 Evolution
+For a manual install, replace `` with the complete final path in the [detailed install guide](INSTALL.md), including the last `distilly` component, and create its parent first:
-- 📥 **Append files** → auto-analyze delta → merge into relevant sections, never overwrite existing conclusions
-- 💬 **Conversation correction** → say "they wouldn't do that, they'd be xxx" → writes to the Correction layer, takes effect immediately
-- 🕰️ **Version control** → auto-archive on every update, rollback to any previous version
-- 🔬 **Celebrity research pipeline** → subtitles → transcript cleanup → six-dimension research → quality check
-
----
+```bash
+git clone --single-branch --branch dot-skill --depth 1 \
+ https://github.com/titanwings/distilly.git \
+
+git -C rev-parse HEAD
+```
-## ⚠️ Notes
+This is an explicit, separate file-based implementation—not an automatic runtime fallback. It does not share a supported data model with the Plugin, and a failed Plugin preflight never switches modes. The compatibility promise currently covers local files and pasted text only. Do not enable legacy collectors while the Plugin uses the same home directory: current legacy collectors can write credential configuration into the same `~/.distilly/` namespace and remain outside the Preview's reviewed security boundary. Keep exactly one `distilly` active in any host discovery scope and verify which copy the host loaded.
-**Source material quality = Person Profile quality** — and quality sources differ across families:
+## The first usable flow
-| Family | Source priority (high → low) |
-|--------|------------------------------|
-| 🧑💼 **colleague** | Their **own long-form writing** (design docs / review comments) **›** **decision-making replies** **›** casual group chat |
-| 💞 **relationship** | Complete chat history **›** letters / social posts / diaries **›** third-party descriptions |
-| 🌟 **celebrity** | First-person books / blogs / long interviews **›** decision records (launches, commits, Q&A) **›** verified first-person short-form posts **›** third-party commentary |
+On Codex, the complete flow below is verified. OpenClaw `2026.3.24` and Hermes `v0.9.0` have the same briefing transport path verified against their recorded capacity fixture; their packaged restart, long-lived Skill, and uninstall lifecycle checks remain separate. Restart the selected host and ask it to research and distill a person. Supply only the files, text, or public URLs you want included. Distilly then:
-- **colleague** Lark-compatible auto-collection: requires adding the App bot to relevant group chats
-- **relationship**: longer time spans are better; material covering both conflict and repair is ideal
-- **celebrity**: avoid feeding only second-hand interpretations
-- This is still a demo version — please file issues if you find bugs!
+1. resolves or creates the person;
+2. imports the selected material with deterministic local parsers;
+3. creates a pending research job and a complete evidence-bound briefing;
+4. commits a versioned Person Profile;
+5. returns the profile or a complete temporary prompt for the current run;
+6. accepts an explicit correction and sends a candidate to review;
+7. lets you promote, reject, or roll back the candidate in the local Panel; and
+8. installs the approved profile as a self-contained host Skill when you ask it to.
----
+The model-facing surface remains exactly five MCP tools:
-## 📄 Technical Report
+`distilly_get` · `distilly_ingest` · `distilly_pending` · `distilly_commit` · `distilly_correct`
-> **[COLLEAGUE.SKILL: Automated AI Skill Generation via Expert Knowledge Distillation](https://arxiv.org/pdf/2605.31264)** ([arXiv](https://arxiv.org/abs/2605.31264) · [arXiv PDF](https://arxiv.org/pdf/2605.31264))
->
-> This is the paper for **COLLEAGUE.SKILL / colleague-skill**, Distilly's predecessor. It covers the Work Skill + Persona two-layer architecture, multi-source data collection, and Skill generation mechanics — the theoretical foundation for today's `colleague` family. Separate papers on the relationship / celebrity family extensions are planned.
+Distilly never silently truncates a complete briefing or profile prompt. If a verified host budget cannot carry the complete value, it reports a bounded capacity error with measurements and keeps the stored data unchanged.
----
+## Host status
-## 📝 Citation
+| Host | Native Plugin | Current compatibility route |
+| --- | --- | --- |
+| Codex | Fully verified in this release branch | Native Plugin |
+| Claude Code | Binding included; exact host fixture still needed | Explicit `dot-skill` Legacy Skill |
+| OpenClaw | Transport-capacity fixture recorded for `2026.3.24` (65,536-byte net budget); lifecycle pending | Claude-compatible bundle + discovery smoke |
+| Hermes | Transport-capacity fixture recorded for `v0.9.0` (49,752-byte net budget); lifecycle pending | Managed Skill + MCP configuration |
+| DeepSeek Harness (DSH) | Community binding planned | Explicit `dot-skill` Legacy Skill |
+| Pi agent | Community binding planned | Explicit `dot-skill` Legacy Skill |
+| Grok Build | Community binding planned | Explicit `dot-skill` Legacy Skill |
+| OpenCode | Community binding planned | Explicit `dot-skill` Legacy Skill |
+| Grok Bot | Community binding planned | Manual saved/private Skill only; local repository import is not claimed |
-If you use **Distilly** or **COLLEAGUE.SKILL** in your research or applications, please cite the technical report:
+Host compatibility is a binding concern. Legacy Skill discovery is useful continuity, but it does not make a host a verified Plugin target.
-```bibtex
-@misc{zhou2026colleagueskill,
- title = {COLLEAGUE.SKILL: Automated AI Skill Generation via Expert Knowledge Distillation},
- author = {Tianyi Zhou and Dongrui Liu and Leitao Yuan and Jing Shao and Xia Hu},
- year = {2026},
- eprint = {2605.31264},
- archivePrefix = {arXiv},
- primaryClass = {cs.AI},
- url = {https://arxiv.org/abs/2605.31264}
-}
-```
+## Local material formats
-You can also use the machine-readable citation metadata in [CITATION.cff](CITATION.cff).
+The first Preview accepts explicit local `TXT`, `Markdown`, `JSON`, and `SRT/VTT` files. It also accepts pasted text and public URLs through the host's visible research flow. Files are read only from the paths or sources the user supplies; symlinked selected files and duplicate file names are rejected. PDF, email, provider exports, and hosted connectors are follow-up work.
----
+## 📣 2026-09 update: help expand coding-agent Plugins
-## ⭐ Star History
+Codex, OpenClaw, and Hermes now have real host/version capacity fixtures. We need community support to provide the same evidence for **Claude Code, DeepSeek Harness (DSH), Pi agent, Grok Build, OpenCode, and Grok Bot**, then to build and validate their coding-agent Plugin packages. I will actively review those contributions and keep the public contracts, release digests, and host behavior aligned.
-
-
-
-
-
-
-
+See the full call for contributors in [UPDATES.md](UPDATES.md) and the current priorities in [ROADMAP.md](ROADMAP.md).
----
+## Project documents
-
+- [Detailed Preview installation](INSTALL.md)
+- [Changelog](CHANGELOG.md)
+- [Architecture and shipped-state map](docs/architecture.md)
+- [Testing contract](docs/testing.md)
+- [Development workflow](docs/development.md)
+- [Design corpus](docs/design/README.md)
+- [Release manifest](plugins/release-manifest.json)
+- [Contributing](CONTRIBUTING.md)
-**MIT License** © [titanwings](https://github.com/titanwings)
+Distilly is released under the [MIT License](LICENSE). Created by [@titanwings](https://github.com/titanwings).
-
diff --git a/SKILL.md b/SKILL.md
index 7f9fbcc9..462b1d70 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -11,11 +11,21 @@ allowed-tools: Read, Write, Edit, Bash
>
> 本 Skill 支持中英文。根据用户第一条消息的语言,全程使用同一语言回复。下方提供了两种语言的指令,按用户语言选择对应版本执行。
-> **Skill Root / Skill 根目录**: Before reading a bundled prompt or running a bundled script, resolve the absolute directory of the `SKILL.md` that the host actually loaded. In the instructions below, `{distilly_skill_root}` means that exact directory. Claude Code exposes it as `${CLAUDE_SKILL_DIR}`; on every other host, use the loaded-skill path supplied by that host's discovery context. Do not assume the shell's current working directory is the Skill root, and do not guess or hard-code an install path. If the host does not expose the loaded path or more than one Distilly installation is ambiguous, ask the user to identify the active installation before running code.
+> **Skill Root / Skill 根目录**: Before reading a bundled prompt or running a bundled command, resolve the absolute directory of the `SKILL.md` that the host actually loaded. In the instructions below, `{distilly_skill_root}` means that exact directory. Claude Code exposes it as `${CLAUDE_SKILL_DIR}`; on every other host, use the loaded-skill path supplied by that host's discovery context. Do not assume the shell's current working directory is the Skill root, and do not guess or hard-code an install path. If the host does not expose the loaded path or more than one Distilly installation is ambiguous, ask the user to identify the active installation before running code.
>
-> Keep the shell in the user's current workspace so relative output paths such as `./skills/...` remain project-local. Resolve every `tools/...` and `prompts/...` resource against `{distilly_skill_root}`. For example, execute the bundled `tools/example.py` as `python3 "{distilly_skill_root}/tools/example.py"`; replace the placeholder with the resolved absolute path in the actual tool call.
+> Keep the shell in the user's current workspace so relative output paths such as `./skills/...` remain project-local. Resolve every `prompts/...` resource against `{distilly_skill_root}`. The only supported entrypoint is the `distilly` CLI; do not call the bundled Python tools directly (they are deprecated, see the migration table below).
>
-> 在读取内置 prompt 或运行脚本前,先取得宿主实际加载的这份 `SKILL.md` 所在绝对目录;下文以 `{distilly_skill_root}` 表示。Claude Code 可用 `${CLAUDE_SKILL_DIR}`,其他宿主使用其 Skill discovery 上下文提供的实际路径。不要假定 shell 当前目录就是 Skill 目录,也不要猜测或硬编码安装路径。shell 应继续停留在用户工作区,使 `./skills/...` 等输出仍写入当前项目;所有 `tools/...`、`prompts/...` 都必须从 `{distilly_skill_root}` 解析。
+> **How to run that CLI**: there is no global `distilly` on `PATH` — the CLI *is* the file inside this Skill. Invoke it as
+> `node "{distilly_skill_root}/bin/distilly.mjs" […]` (it is also executable, so `"{distilly_skill_root}/bin/distilly.mjs" ` works once the file mode survives the copy). Every command in the tables below is written `distilly ` as shorthand for that. If `node` is missing, say so and stop rather than reimplementing a step by hand.
+>
+> **Where things are written**: `--base-dir ` means the workspace root (the directory holding `skills/`) in *every* command — `harvest`, `retrospect`, `view`, `doctor`, `skill`. When a command needs the level that directly contains `/` instead, that is `--skills-dir ` (`skill …`) or `--dir ` (`retrospect`). Never pass both `--base-dir` and `--skills-dir`; the CLI rejects it rather than guessing.
+>
+> 在读取内置 prompt 或执行内置命令前,先取得宿主实际加载的这份 `SKILL.md` 所在绝对目录;下文以 `{distilly_skill_root}` 表示。Claude Code 可用 `${CLAUDE_SKILL_DIR}`,其他宿主使用其 Skill discovery 上下文提供的实际路径。不要假定 shell 当前目录就是 Skill 目录,也不要猜测或硬编码安装路径。shell 应继续停留在用户工作区,使 `./skills/...` 等输出仍写入当前项目;所有 `prompts/...` 都必须从 `{distilly_skill_root}` 解析。唯一受支持的入口是 `distilly` CLI,不要直接调用仓库里的 Python 工具(它们已废弃,见下方迁移对照表)。
+>
+> **这个 CLI 怎么调**:`PATH` 上没有全局 `distilly`——CLI 就是本 Skill 目录里的那个文件。写成
+> `node "{distilly_skill_root}/bin/distilly.mjs" <子命令> […]`(该文件也是可执行的,拷贝时若保留了执行位,`"{distilly_skill_root}/bin/distilly.mjs" <子命令>` 也可以)。下文所有表格里的 `distilly <子命令>` 都是它的简写。没有 `node` 就如实说明并停下,不要手工重做某一步。
+>
+> **东西写到哪**:`--base-dir <工作区>` 在**每一条**命令里都表示工作区根(下面有 `skills/`)——`harvest`、`retrospect`、`view`、`doctor`、`skill` 一致。需要"直接存放 `/` 的那一层"时用 `--skills-dir `(`skill …`)或 `--dir <人物目录>`(`retrospect`)。两个同时给会被拒绝,而不是猜一个。
# Distilly 创建器
@@ -52,613 +62,233 @@ Grok Bot 可以把流程保存为 private Skill,但目前没有官方的本地
兼容更新别名:
- `/update-colleague {slug}`
-当用户要求查看已生成的 Skill 时,执行下方“管理操作”里的列出命令。
+当用户要求查看已生成的 Skill 时,执行下方"管理操作"里的列出命令。
---
-## 工具使用规则
+## 命令契约(唯一入口)
-本 Skill 运行在任意兼容宿主中,只要求宿主能够读取本地文件并执行 Bash / Python 命令。使用以下工具约定:
+所有采集、派生、渲染都走 `distilly`。命令名与 `docs/v2/CONTRACT.md` §1 的命令表逐字一致;不要发明子命令或字段。
-| 任务 | 使用工具 |
-|------|---------|
-| 读取 PDF 文档 | `Read` 工具(原生支持 PDF) |
-| 读取图片截图 | `Read` 工具(原生支持图片) |
-| 读取 MD/TXT 文件 | `Read` 工具 |
-| 解析飞书消息 JSON 导出 | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_parser.py"` |
-| 飞书全自动采集(推荐) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_auto_collector.py"` |
-| 飞书文档(浏览器登录态) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_browser.py"` |
-| 飞书文档(MCP App Token) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_mcp_client.py"` |
-| 钉钉全自动采集 | `Bash` → `python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py"` |
-| 采集公开 X 帖子候选证据 | `Bash` → `python3 "{distilly_skill_root}/tools/research/xquik_public_posts.py"` |
-| 解析邮件 .eml/.mbox | `Bash` → `python3 "{distilly_skill_root}/tools/email_parser.py"` |
-| 写入/更新 Skill 文件 | `Write` / `Edit` 工具 |
-| 版本管理 | `Bash` → `python3 "{distilly_skill_root}/tools/version_manager.py"` |
-| 列出已有 Skill | `Bash` → `python3 "{distilly_skill_root}/tools/skill_writer.py" --action list` |
-
-**基础目录**:
-- `colleague` → `./skills/colleague/{slug}/`
-- `relationship` → `./skills/relationship/{slug}/`
-- `celebrity` → `./skills/celebrity/{slug}/`
-
-如需改为全局路径,用 `--base-dir` 指向对应 character family 的根目录。
+| 任务 | 命令 |
+|------|------|
+| 零凭据:目录/文件 → `knowledge/` | `distilly harvest ` |
+| 解析 ChatGPT / Claude / Slack / Telegram / Discord 导出 | `distilly parse-chat ` |
+| 解析邮件 | `distilly parse-email ` |
+| 解析字幕 | `distilly parse-subtitle ` |
+| 解析文档 | `distilly parse-doc ` |
+| 解析归档(X 官方归档 / Takeout / 社交平台导出) | `distilly parse-archive ` |
+| 纯派生 → `evidence/derived/*.json` | `distilly retrospect` |
+| 需要 key / OAuth 的渠道采集 | `distilly collect ` |
+| 浏览器 computer use(必须带同意 token) | `distilly collect x --mode browser --consent ` |
+| 音视频转写(可选后端) | `distilly transcribe ` |
+| 把 LLM 自己读到的内容登记进账本 | `distilly note --from ` |
+| 同意授权管理 | `distilly consent ` |
+| 视图检查与渲染 | `distilly view check`;`distilly view render [--shareable]` |
+| 证据体检 | `distilly doctor` |
+| 生成 Skill 的创建/更新/列出/版本 | `distilly skill ` |
+| 宿主安装 | `distilly install `;`distilly uninstall` |
+
+- 所有子命令支持 `--json` 回执;`--help` 有中文/英文两段。
+- 需要 key / OAuth 的渠道:先向用户说明将读取哪个渠道、可拿到什么,拿到同意后才运行 `distilly collect`。
+- computer-use 类命令必须带 `--consent `;没有 token 时命令以 `exit 2` 结束并在回执里写"等待用户同意"。
+- 密钥只从 `~/.distilly/*_config.json` 或环境变量读取;回执、日志、对话里只出现配置文件名,永不出现值。
+
+### 迁移对照表(旧写法一律 deprecated)
+
+| 旧写法(deprecated) | 新写法 |
+|----------------------|--------|
+| `tools/feishu_parser.py`(deprecated) | `distilly parse-chat` |
+| `tools/feishu_auto_collector.py`(deprecated) | `distilly collect feishu` |
+| `tools/feishu_browser.py`(deprecated) | `distilly collect feishu --mode browser --consent ` |
+| `tools/feishu_mcp_client.py`(deprecated) | `distilly collect feishu` |
+| `tools/dingtalk_auto_collector.py`(deprecated) | `distilly collect dingtalk` |
+| `tools/email_parser.py`(deprecated) | `distilly parse-email` |
+| `tools/research/xquik_public_posts.py`(deprecated) | `distilly collect x` |
+| `tools/research/transcribe_audio.py`(deprecated) | `distilly transcribe` |
+| `tools/research/srt_to_transcript.py`(deprecated) | `distilly parse-subtitle` |
+| `tools/skill_writer.py`(deprecated) | `distilly skill create` / `distilly skill update` / `distilly skill list` |
+| `tools/version_manager.py`(deprecated) | `distilly skill version` |
+| `tools/install_generated_skill.py`(deprecated) | `distilly install ` |
+| `tools/research/quality_check.py`(deprecated) | `distilly doctor` |
+| `tools/research/merge_research.py`(deprecated) | 暂无契约替代:只做派生,走 `distilly retrospect`,研究笔记合并细节见已知缺口 |
+| `tools/research/download_subtitles.sh`(deprecated) | 暂无契约替代:先让用户提供本地字幕文件,再走 `distilly parse-subtitle` |
+
+迁移期允许两者并存,但新写法优先;只要 Python 工具还在被引用,就必须保留 `deprecated` 标注。
---
-## 主流程:创建新 Skill
-
-### Step 0:确认 character family
-
-如果用户使用的是 `/distilly`,先确认本次要蒸馏的是哪一类:
-
-1. `colleague`
-2. `relationship`
-3. `celebrity`
-
-如果上层宿主已经显式把 family 传进来,则直接固定对应的 character family。
-
-如果当前 family 是 `celebrity`,还必须确认 research profile:
-
-1. `budget-friendly`
-2. `budget-unfriendly`
-
-默认使用 `budget-friendly`。只有当用户明确要求更深研究、更高置信度、或者愿意接受更慢更贵的蒸馏流程时,才切到 `budget-unfriendly`。
-
-### Step 1:基础信息录入
-
-根据 character family 选择对应 intake prompt:
-
-- `colleague` → `prompts/intake.md`
-- `relationship` → `prompts/relationship/intake.md`
-- `celebrity` → `prompts/celebrity/intake.md`
-
-`colleague` 和 `relationship` 只问 3 个问题。
-`celebrity` 按 `prompts/celebrity/intake.md` 问 4 个问题,其中第 4 个问题必须确认 `research_profile`。
-
-默认的 3 个基础问题:
-
-1. **花名/代号**(必填)
-2. **基本信息**(一句话:公司、职级、职位、性别,想到什么写什么)
- - 示例:`字节 2-1 后端工程师 男`
-3. **性格画像**(一句话:MBTI、星座、个性标签、企业文化、印象)
- - 示例:`INTJ 摩羯座 甩锅高手 字节范 CR很严格但从来不解释原因`
-
-除姓名外均可跳过。收集完后汇总确认,再进入下一步。
-
-### Step 2:原材料导入
-
-询问用户提供原材料,展示四种方式供选择:
+## 磁盘契约(LLM 只能写这些)
```
-原材料怎么提供?
+skills///
+ SKILL.md work.md persona.md work_skill.md persona_skill.md manifest.json meta.json
+ knowledge/{docs,messages,emails}/
+ knowledge/raw//... # 原样字节,只增不改
+ knowledge/text/.md # 归一化正文,段落锚点 [k0012] / [k0012:t3]
+ knowledge/index.json # 账本 {id,kind,origin,fetched_at,bytes,sha256,credentialed,method,warnings[]}
+ evidence/derived/*.json # retrospect 派生,每条结论带 evidence 锚点
+ views/.view.json # LLM 只写章节/顺序/强调(不含事实)
+ views/.html # render 产物:单文件、离线、双主题
+ evidence/renders/receipt.json # render 回执(sha256 + 字节数 + 内联来源)
+```
- [A] 飞书自动采集(推荐)
- 输入姓名,自动拉取消息记录 + 文档 + 多维表格
+- LLM 可以写:`views/.view.json`(只写章节、顺序、强调)、临时工作文件、以及通过 `distilly note --from ` 登记的"model-read"来源。
+- LLM 不可以写:`knowledge/raw/**`(原样字节,只增不改)、`knowledge/index.json`、`evidence/derived/*.json`(由 `distilly retrospect` 生成)、`evidence/renders/receipt.json`。
+- 截图、回执图、diff 图不入库(`.gitignore` 已含 `dst-evidence/`);本地产物放 `/tmp/dst-evidence//`。
+- 锚点格式统一为 `[k00NN]`(4 位补零)或 `[k00NN:tM]`(带轮次)。任何结论必须带 `文件 + 锚点`,没有证据就写 `unknown`。
- [B] 钉钉自动采集
- 输入姓名,自动拉取文档 + 多维表格
- 消息记录通过浏览器采集(钉钉 API 不支持历史消息)
+---
- [C] 飞书链接
- 直接给文档/Wiki 链接(浏览器登录态 或 MCP)
+## 五步主线
- [D] 上传文件
- PDF / 图片 / 导出 JSON / 邮件 .eml
+创建、追加、纠正都走同一条主线:**Collect → Derive → Read → Distill → Render**。
- [E] 直接粘贴内容
- 把文字复制进来
+| 步骤 | 必须存在的产物 | 计数判据 | sha256 从哪来 | 失败怎么办 |
+|------|----------------|----------|---------------|------------|
+| 1 Collect | `knowledge/raw//**`、`knowledge/text/.md`、`knowledge/index.json` | 每个落地来源 1 条账本条目;每个 text 文件 ≥1 个锚点 | `distilly --json` 回执的 `outputs[].sha256`,与 `knowledge/index.json` 的 `sha256` 逐字节一致 | 非零退出:记录命令、stderr、补救步骤;0 条落地来源时停下,不得进入 Derive |
+| 2 Derive | `evidence/derived/*.json` | 每条派生结论带 evidence 锚点;连跑两次字节相同 | 回执 `outputs[].sha256`;两次运行 sha256 相同 | 非零退出:先修 `knowledge/index.json` 完整性;不得手写派生 JSON |
+| 3 Read | 无新文件,产出"读了什么"的复述 | 按文件列出:文件 → 条数 → 锚点数 | 引用 `knowledge/index.json` 的 `sha256`,不自算 | 文件缺失或锚点为 0:回到 Step 1 补齐,不得凭记忆写结论 |
+| 4 Distill | `work.md`、`persona.md`,celebrity 另有 research/audit/synthesis/validation | 每个维度有锚点或 `unknown`;celebrity 有明确 `PASS/FAIL` | 引用被引用的来源 sha256(来自账本) | 证据不足:标 `(原材料不足)` / candidate,并说明需要补什么材料 |
+| 5 Render | `views/.view.json`、`views/.html`、`evidence/renders/receipt.json` | 回执 sha256 与 html 实际 sha256 一致;`distilly doctor` 锚点回指率可查 | `evidence/renders/receipt.json` 的 sha256 | 渲染失败:保留 view.json,不发布,报告错误 |
-可以混用,也可以跳过(仅凭手动信息生成)。
-```
+任何一步的失败都不允许"静默降级":要么修好,要么把失败写进对用户的汇报和回执的 `warnings[]` / `unavailable[]`。
----
+### Step 1:Collect(采集)
-#### 方式 A:飞书自动采集(推荐)
+1. 先读 `prompts/collectors.md`,按"什么时候用哪条命令"选路。
+2. 零凭据来源(本地文件、导出包、字幕、文档、归档)直接走 `distilly harvest`、`distilly parse-chat`、`distilly parse-email`、`distilly parse-subtitle`、`distilly parse-doc`、`distilly parse-archive`。
+3. 需要 key / OAuth 的渠道(飞书、Slack、钉钉、X、Discord、Reddit、Notion、Gmail)先征求用户同意,再走 `distilly collect `;同意范围用 `distilly consent ` 管理。
+4. 浏览器 computer use 必须按 `prompts/computer-use.md` 执行:先问再动、只读白名单、默认 ≤20 屏 / ≤10 分钟 / 每分钟 ≤6 次滚动、每屏落盘原文 + URL + 时间 + 截图(截图只放本地)、可中断;`distilly collect x --mode browser --consent ` 没有 token 就直接退出,不要绕过。
+5. 用户只能"贴文字/截图"时,用 `distilly note --from ` 登记来源(`method:"model-read"`),不要假装它是采集来的。
+6. 音视频先 `distilly transcribe`,再解析字幕;不要把整段 transcript 抄进仓库。
-首次使用需配置:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" --setup
-```
+**完成判据**:`knowledge/index.json` 里每个落地来源一条账本条目(含 `id`、`kind`、`origin`、`fetched_at`、`bytes`、`sha256`、`credentialed`、`method`、`warnings[]`);每个 `knowledge/text/.md` 至少 1 个锚点;回执 `inputs[]`/`outputs[]` 的 sha256 与账本一致;不可用渠道进 `unavailable[]`。
+**失败怎么办**:命令非零退出时,把命令原文、stderr、补救步骤(例如缺凭据要配置哪个 `~/.distilly/*_config.json`)告诉用户,然后停下等指示;如果 0 条来源落地,不要进入 Step 2。
-**群聊采集**(使用 tenant_access_token,需 bot 在群内):
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 1000 \
- --doc-limit 20
-```
+### 第 1.5 步:语料体检(进入 Derive 之前)
-**私聊采集**(需要 user_access_token + 私聊 chat_id):
-
-私聊消息只能通过用户身份(user_access_token)获取,应用身份无权访问私聊。
-
-**前置条件**:
-
-用户需要提供以下信息:
-1. **飞书应用凭证**:`app_id` 和 `app_secret`(在飞书开放平台创建自建应用获取)
-2. **用户权限**:应用需开通以下用户权限(scope):
- - `im:message` — 以用户身份读取/发送消息
- - `im:chat` — 以用户身份读取会话列表
-3. **OAuth 授权码(code)**:用户在浏览器中完成 OAuth 授权后,从回调 URL 中获取
-
-如果用户缺少以上任何信息,引导他们完成配置。不要假设用户已经配好了。
-
-**获取 user_access_token 的完整流程**:
-
-当用户提供了 app_id、app_secret,并确认已开通用户权限后:
-
-1. 帮用户生成 OAuth 授权链接:
- ```
- https://open.feishu.cn/open-apis/authen/v1/authorize?app_id={APP_ID}&redirect_uri=http://www.example.com&scope=im:message%20im:chat
- ```
- > ⚠️ 注意:`redirect_uri` 需要在飞书应用的「安全设置 → 重定向 URL」中添加 `http://www.example.com`
-
-2. 用户在浏览器打开链接,登录并授权
-3. 页面会跳转到 `http://www.example.com?code=xxx`,用户复制 code 给你
-4. 用 code 换取 token:
- ```bash
- python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" --exchange-code {CODE}
- ```
- 或者你自己写 Python 脚本调飞书 API 换取:
- ```python
- # 1. 获取 app_access_token
- POST https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal
- Body: {"app_id": "xxx", "app_secret": "xxx"}
-
- # 2. 用 code 换 user_access_token
- POST https://open.feishu.cn/open-apis/authen/v1/oidc/access_token
- Header: Authorization: Bearer {app_access_token}
- Body: {"grant_type": "authorization_code", "code": "xxx"}
- ```
-
-**获取私聊 chat_id**:
-
-用户通常不知道 chat_id。当用户有了 user_access_token 但没有 chat_id 时,你应该**自己写 Python 脚本**来获取:
-
-- **方法**:用 user_access_token 向对方的 open_id 发一条消息,返回值中会包含 chat_id
- ```python
- POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id
- Header: Authorization: Bearer {user_access_token}
- Body: {"receive_id": "{对方open_id}", "msg_type": "text", "content": "{\"text\":\"你好\"}"}
- # 返回值中的 chat_id 就是私聊会话 ID
- ```
-- **注意**:`GET /im/v1/chats` 不会返回私聊会话,这是飞书 API 的限制,不是权限问题,不要尝试用这个接口找私聊
-- 如果用户不知道对方的 open_id,可以用 tenant_access_token 调通讯录 API 搜索:
- ```python
- GET https://open.feishu.cn/open-apis/contact/v3/scopes
- # 返回应用可见范围内所有用户的 open_id
- ```
-
-**执行采集**:
-
-拿到 user_access_token 和 chat_id 后:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" \
- --open-id {对方open_id} \
- --p2p-chat-id {chat_id} \
- --user-token {user_access_token} \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 1000
-```
+1. 采集完成后先跑 `distilly doctor --require-shape`,读回执里的 `shape[]`。
+2. `shape[].verdict === "FAIL"` 时**停下**,把 `shape[].reasons[]` 原文告诉用户,并说明要补什么
+ (典型:这是多人材料但只有 3% 的单元能归到某个说话人——需要这个人自己的产出:
+ 本人访谈/演讲字幕、本人文章、本人邮件,而不是会议流水)。
+3. 常见阈值(写在 `shape[].reasons[]` 里,不用背):可引用单元 < 20 → FAIL;多人材料但可归属
+ 单元 < 40%,或最活跃的人 < 20% → FAIL。没有说话人标注**不**判失败(本人文章、单人录音是正常的)。
+4. `verdict` 是 `PASS` 才继续 Step 2。
-**灵活性原则**:以上 API 调用不一定要用 collector 脚本,如果脚本跑不通或者场景不匹配,你可以直接写 Python 脚本调飞书 API 完成任务。核心 API 参考:
-- 获取 token:`POST /auth/v3/app_access_token/internal`、`POST /authen/v1/oidc/access_token`
-- 发消息(获取 chat_id):`POST /im/v1/messages?receive_id_type=open_id`
-- 拉消息:`GET /im/v1/messages?container_id_type=chat&container_id={chat_id}`
-- 查通讯录:`GET /contact/v3/scopes`、`GET /contact/v3/users/{user_id}`
-
-自动采集内容:
-- 群聊:所有与他共同群聊中他发出的消息(过滤系统消息、表情包)
-- 私聊:与他的私聊完整对话(含双方消息,用于理解对话语境)
-- 他创建/编辑的飞书文档和 Wiki
-- 相关多维表格(如有权限)
-
-采集完成后用 `Read` 读取输出目录下的文件:
-- `knowledge/{slug}/messages.txt` → 消息记录(群聊 + 私聊)
-- `knowledge/{slug}/docs.txt` → 文档内容
-- `knowledge/{slug}/collection_summary.json` → 采集摘要
-
-如果采集失败,根据报错自行判断原因并尝试修复,常见问题:
-- 群聊采集:bot 未添加到群聊
-- 私聊采集:user_access_token 过期(有效期 2 小时,可用 refresh_token 刷新)
-- 权限不足:引导用户在飞书开放平台开通对应权限并重新授权
-- 或改用方式 B/C
+**为什么有这一步**:不先问"这份材料撑不撑得起一个人",就会一路跑到 Step 4,产出的是
+"会议室的画像"而不是"这个人的画像"。这件事真实发生过一次。
----
+### Step 2:Derive(派生)
-#### 方式 B:钉钉自动采集
+1. 派生之前不要读 `evidence/derived/*`——先跑 `distilly retrospect`。
+2. `distilly retrospect` 只做纯派生:输入是 `knowledge/**`,输出是 `evidence/derived/*.json`,每条结论带 evidence 锚点。
+3. 为验证确定性,连跑两次;同一输入两次的 sha256 必须相同。
-首次使用需配置:
-```bash
-python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py" --setup
-```
+**完成判据**:`evidence/derived/*.json` 存在;回执给出 `anchors.total` / `anchors.cited`;两次运行 `outputs[].sha256` 相同。
+**失败怎么办**:非零退出说明输入侧有问题——回到 Step 1 检查账本与 text 锚点;绝不手写、手改派生 JSON 来"跑通"。
-然后输入姓名,一键采集:
-```bash
-python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py" \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 500 \
- --doc-limit 20 \
- --show-browser # 首次使用加此参数,完成钉钉登录
-```
+### Step 3:Read(阅读)
-采集内容:
-- 他创建/编辑的钉钉文档和知识库
-- 多维表格
-- 消息记录(⚠️ 钉钉 API 不支持历史消息拉取,自动切换浏览器采集)
+1. 读的顺序:`knowledge/index.json` → `knowledge/text/*.md` → `evidence/derived/*.json`。
+2. 先向用户复述"读了哪些文件、各多少条、多少锚点",再写结论。
+3. 每条结论后面跟 `文件 + 锚点`(例如 `knowledge/text/feishu.md [k0042]`)。
+4. 找不到证据的结论写 `unknown`,并说明缺什么材料可以补上。
+5. 事实与候选分开:有具体锚点支撑的才算事实;派生文件里的模式、倾向、推断一律按候选处理,候选不能升级为结论。
+6. 全文细节规范见 `prompts/retrospection.md`。
-采集完成后 `Read` 读取:
-- `knowledge/{slug}/docs.txt`
-- `knowledge/{slug}/bitables.txt`
-- `knowledge/{slug}/messages.txt`
+**完成判据**:复述清单里的每个文件都能在账本里回指;被引用的锚点都真实存在于 `knowledge/text/**`;没有无锚点的结论。
+**失败怎么办**:文件缺失或锚点为 0 时回到 Step 1 补齐;不要凭记忆或常识补写内容。
-如消息采集失败,提示用户截图聊天记录后上传。
+### Step 4:Distill(蒸馏)
----
+先用第 0 步确认的 family 解析执行矩阵:
-#### 方式 D:上传文件
-
-- **PDF / 图片**:`Read` 工具直接读取
-- **飞书消息 JSON 导出**:
- ```bash
- python3 "{distilly_skill_root}/tools/feishu_parser.py" --file {path} --target "{name}" --output /tmp/feishu_out.txt
- ```
- 然后 `Read /tmp/feishu_out.txt`
-- **邮件文件 .eml / .mbox**:
- ```bash
- python3 "{distilly_skill_root}/tools/email_parser.py" --file {path} --target "{name}" --output /tmp/email_out.txt
- ```
- 然后 `Read /tmp/email_out.txt`
-- **Markdown / TXT**:`Read` 工具直接读取
+| character | intake | persona analyzer | persona builder | merger | storage root |
+|-----------|--------|------------------|-----------------|--------|--------------|
+| `colleague` | `prompts/intake.md` | `prompts/persona_analyzer.md` | `prompts/persona_builder.md` | `prompts/merger.md` | `./skills/colleague/{slug}` |
+| `relationship` | `prompts/relationship/intake.md` | `prompts/relationship/persona_analyzer.md` | `prompts/relationship/persona_builder.md` | `prompts/relationship/merger.md` | `./skills/relationship/{slug}` |
+| `celebrity` | `prompts/celebrity/intake.md` | `prompts/celebrity/persona_analyzer.md` | `prompts/celebrity/persona_builder.md` | `prompts/celebrity/merger.md` | `./skills/celebrity/{slug}` |
----
+所有 family 共用:Work analyzer `prompts/work_analyzer.md`、Work builder `prompts/work_builder.md`、Correction handler `prompts/correction_handler.md`。
-#### 方式 C:飞书链接
+两条线:
-用户提供飞书文档/Wiki 链接时,询问读取方式:
+- **线路 A(Work Skill)**:参考 `prompts/work_analyzer.md`,提取负责系统、技术规范、工作流程、输出偏好、经验知识;celebrity 场景下 `work` 更偏方法论、判断框架、决策习惯。
+- **线路 B(Persona)**:用当前 family 的 persona analyzer;`celebrity` + `research_profile=budget-unfriendly` 时改用 `prompts/celebrity/budget_unfriendly/persona_analyzer.md`。把用户填的标签翻译为具体行为规则,并从材料里提取表达风格、决策模式、人际行为。
-```
-检测到飞书链接,选择读取方式:
-
- [1] 浏览器方案(推荐)
- 复用你本机 Chrome 的登录状态
- ✅ 内部文档、需要权限的文档都能读
- ✅ 无需配置 token
- ⚠️ 需要本机安装 Chrome + playwright
-
- [2] MCP 方案
- 通过飞书 App Token 调用官方 API
- ✅ 稳定,不依赖浏览器
- ✅ 可以读消息记录(需要群聊 ID)
- ⚠️ 需要先配置 App ID / App Secret
- ⚠️ 内部文档需要管理员给应用授权
-
-选择 [1/2]:
-```
+写文件时不要手工拼 `skills/{family}/{slug}` 文件树,统一走 writer:把 `meta.json` / `work.md` / `persona.md` 写到临时文件,再调 `distilly skill create`(或 `distilly skill update`)。人物 Skill 的安装走 `distilly install `。
-**选 1(浏览器方案)**:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_browser.py" \
- --url "{feishu_url}" \
- --target "{name}" \
- --output /tmp/feishu_doc_out.txt
-```
-首次使用若未登录,会弹出浏览器窗口要求登录(一次性)。
+**完成判据**:每个维度都有锚点或明确的 `(原材料不足)`;每条行为规则具体可执行;celebrity 的 audit / validation 给出明确 `PASS` 或 `FAIL`;`distilly doctor` 能报告证据覆盖率、不可用渠道、锚点回指率。celebrity 场景下的 research 门槛见下方子流程。
+**失败怎么办**:证据不足的维度标 `(原材料不足,建议追加相关文档)` 并降级为 candidate;`source_grounding` 不达标时保留 `FAIL` 并说明还缺什么,绝不用泛化链接刷过检查。
-**选 2(MCP 方案)**:
+### Step 5:Render(渲染)
-首次使用需初始化配置:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" --setup
-```
+1. 先 `distilly view check`,确认锚点都能回指到 `knowledge/index.json`。
+2. 写 `views/.view.json`:只写章节、顺序、强调,不写事实。
+3. `distilly view render` 生成单文件、离线、双主题的 `views/.html`,并写 `evidence/renders/receipt.json`(sha256 + 字节数 + 内联来源)。
+4. 对外分享时才用 `distilly view render --shareable`,并先让用户确认。
+5. 用 `distilly doctor` 复核证据覆盖率、不可用渠道、锚点回指率、computer-use 占比。
-之后直接读取:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" \
- --url "{feishu_url}" \
- --output /tmp/feishu_doc_out.txt
-```
+**完成判据**:`views/.html` 与 `evidence/renders/receipt.json` 同时存在;回执 sha256 与 html 实际 sha256 一致;内部链接 0 坏链。
+**失败怎么办**:渲染失败时保留 `views/.view.json`,不发布 HTML,把错误与缺失来源报告给用户。
-读取消息记录(需要群聊 ID,格式 `oc_xxx`):
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" \
- --chat-id "oc_xxx" \
- --target "{name}" \
- --limit 500 \
- --output /tmp/feishu_msg_out.txt
-```
+### 第 0 步(前置):确认 family 与 intake
-两种方式输出后均用 `Read` 读取结果文件,进入分析流程。
+如果用户使用的是 `/distilly`,先确认本次要蒸馏的是哪一类:
----
+1. `colleague`
+2. `relationship`
+3. `celebrity`
-#### 方式 E:直接粘贴
+如果上层宿主已经显式把 family 传进来,则直接固定对应的 character family。
-用户粘贴的内容直接作为文本原材料,无需调用任何工具。
+如果当前 family 是 `celebrity`,还必须确认 research profile:
----
+1. `budget-friendly`
+2. `budget-unfriendly`
-如果用户说"没有文件"或"跳过",仅凭 Step 1 的手动信息生成 Skill。
+默认使用 `budget-friendly`。只有当用户明确要求更深研究、更高置信度、或者愿意接受更慢更贵的蒸馏流程时,才切到 `budget-unfriendly`。
-### Step 3:分析原材料
+根据 family 选择 intake prompt:`colleague` → `prompts/intake.md`;`relationship` → `prompts/relationship/intake.md`;`celebrity` → `prompts/celebrity/intake.md`。`colleague` 和 `relationship` 只问 3 个问题;`celebrity` 问 4 个问题,其中第 4 个必须确认 `research_profile`。
-先根据 character family 解析本次的执行矩阵:
+默认的 3 个基础问题:
-| character | intake | persona analyzer | persona builder | merger | storage root |
-|-----------|--------|------------------|-----------------|--------|--------------|
-| `colleague` | `prompts/intake.md` | `prompts/persona_analyzer.md` | `prompts/persona_builder.md` | `prompts/merger.md` | `./skills/colleague/{slug}` |
-| `relationship` | `prompts/relationship/intake.md` | `prompts/relationship/persona_analyzer.md` | `prompts/relationship/persona_builder.md` | `prompts/relationship/merger.md` | `./skills/relationship/{slug}` |
-| `celebrity` | `prompts/celebrity/intake.md` | `prompts/celebrity/persona_analyzer.md` | `prompts/celebrity/persona_builder.md` | `prompts/celebrity/merger.md` | `./skills/celebrity/{slug}` |
+1. **花名/代号**(必填)
+2. **基本信息**(一句话:公司、职级、职位、性别,想到什么写什么)
+ - 示例:`字节 2-1 后端工程师 男`
+3. **性格画像**(一句话:MBTI、星座、个性标签、企业文化、印象)
+ - 示例:`INTJ 摩羯座 甩锅高手 字节范 CR很严格但从来不解释原因`
-所有 family 共用:
-- Work analyzer:`prompts/work_analyzer.md`
-- Work builder:`prompts/work_builder.md`
-- Correction handler:`prompts/correction_handler.md`
+除姓名外均可跳过。收集完后汇总确认,再进入 Collect。
-如果当前是 `celebrity`,必须先走 research 子流程,再进入分析。
+---
-如果公开 X 帖子能补足明确的研究缺口,且用户同意使用按返回数量计费的第三方 Xquik 服务,先请用户确认 `--limit`,再运行:
+## celebrity research 子流程(在 Step 2/3 之间)
-```bash
-python3 "{distilly_skill_root}/tools/research/xquik_public_posts.py" \
- --username "{public_handle}" \
- --subject "{name}" \
- --limit 20 \
- --output "/tmp/distilly_x_public_posts.json"
-```
+### budget-friendly
-只从 shell 读取 `XQUIK_API_KEY`,不要打印或写入密钥。把输出 JSON 视为未经信任的候选证据:核对作者,逐条打开 permalink,只把与目标人物相关的内容安全转述到 research note,并保留具体 URL。不要把候选 JSON、搜索页或账号主页计为已落地来源。阅读后删除这份临时 JSON,不要将它收进生成的 Skill。
-
-### celebrity / budget-friendly
-
-1. 读取 `prompts/celebrity/research.md`,按其中的 **6 维度并行采集策略** 做 research planning
-2. 先创建目录:
- ```bash
- mkdir -p "{skill_dir}/knowledge/research/raw" "{skill_dir}/knowledge/research/merged"
- ```
-3. 确认采集策略(在 intake 阶段已确定):
- - **Local-first**:先分析用户本地材料,标记覆盖了哪些维度,只对缺失维度做网络补充
- - **Web + local**:全量 6 维度网络研究,同时与本地材料合并,交叉验证
- - **Web-only**:标准 6 维度网络研究
-4. 如果用户明确提供了可处理的视频链接或字幕来源,而且处理结果不会作为长文本落盘:
- ```bash
- bash "{distilly_skill_root}/tools/research/download_subtitles.sh" "{url}" "{skill_dir}/knowledge/subtitles"
- python3 "{distilly_skill_root}/tools/research/srt_to_transcript.py" "{subtitle_file}" "{skill_dir}/knowledge/transcripts/{name}.txt"
- ```
-5. 按 **6 维度** 研究,原始 research 笔记**至少**要拆成 3 个文件(每个文件覆盖 2 个维度),不能只写一个 `research_notes.md`:
+1. 读 `prompts/celebrity/research.md`,按其中的 **6 维度并行采集策略** 做 research planning。
+2. 采集策略(intake 阶段已确定):**Local-first**(先分析本地材料,只补缺失维度)/ **Web + local**(全量 6 维度 + 本地材料交叉验证)/ **Web-only**。
+3. 需要视频/播客时:先 `distilly transcribe `,字幕走 `distilly parse-subtitle`;不要把完整 transcript 落进仓库。
+4. 原始 research 笔记**至少**拆成 3 个文件(每个覆盖 2 个维度),不能只写一个 `research_notes.md`:
- `knowledge/research/raw/01_core_profile.md`(维度 1 著作 + 维度 6 时间线)
- `knowledge/research/raw/02_conversations_and_material.md`(维度 2 对话 + 维度 4 决策)
- `knowledge/research/raw/03_expression_and_reception.md`(维度 3 表达 DNA + 维度 5 他者视角)
-6. 研究过程中必须遵守 **品味原则**(详见 research prompt):
- - 长文 > 金句,争议 > 共识,变化 > 固定,一手 > 二手
- - 遵守 **信源黑名单**:永不引用知乎、微信公众号、百度百科、内容农场
- - 遵守 **信源优先级**:用户本地材料 > 一手著作 > 长访谈 > 决策记录 > 社交媒体 > 外部分析 > 二手转述
-7. 合并 research:
- ```bash
- python3 "{distilly_skill_root}/tools/research/merge_research.py" "{skill_dir}"
- ```
- 输出:`knowledge/research/merged/summary.md`
-8. 读取 `knowledge/research/merged/summary.md`,确认:
- - `Files scanned >= 3`
- - `Unique URLs >= 2`
- - `Potential long quote lines = 0`
- - research notes 里的 URL 必须是**实际打开过的具体页面**,不是平台首页、搜索页、话题页或占位路径
- 如果不满足,继续补 research notes,直到满足或明确记录搜集受限原因。
-9. **质量关卡(Phase 1.5)**:在进入分析之前,必须向用户展示结构化采集摘要:
- ```
- ┌──────────────────────────────┬──────────┬─────────────────────────────┐
- │ 维度 │ 来源数 │ 关键发现 │
- ├──────────────────────────────┼──────────┼─────────────────────────────┤
- │ 1 著作 │ N │ [核心论点 / 缺失] │
- │ 2 对话 │ N │ [关键模式 / 缺失] │
- │ 3 表达 DNA │ N │ [风格标记 / 缺失] │
- │ 4 决策 │ N │ [决策模式 / 缺失] │
- │ 5 他者视角 │ N │ [外部观点 / 缺失] │
- │ 6 时间线 │ N │ [认知轨迹 / 缺失] │
- ├──────────────────────────────┼──────────┼─────────────────────────────┤
- │ 矛盾点 │ N │ [摘要] │
- │ 薄弱维度 │ [列表] │ 补充方案:[计划] │
- │ 冷门人物? │ 是/否 │ │
- └──────────────────────────────┴──────────┴─────────────────────────────┘
- ```
- 等待用户确认后再继续。如果用户指出问题或需要某个维度更深入,先补充研究。
-10. **冷门人物检测**:如果总来源 < 10 条,按冷门人物协议处理:
- - 心智模型限制为 2–3 个
- - 薄弱模型标注"基于有限信息"
- - 扩大诚实边界章节
- - 告知用户提供什么补充材料可以改善质量
-11. celebrity 的后续分析输入必须优先使用:
- - 一手材料(信源权重 1-3)
- - merged research summary
- - 用户提供的补充描述
-
-### celebrity / budget-unfriendly
-
-1. 先读取:
- - `prompts/celebrity/budget_unfriendly/research.md`
- - `references/celebrity_budget_unfriendly_framework.md`
-2. 先创建目录:
- ```bash
- mkdir -p "{skill_dir}/knowledge/research/raw" "{skill_dir}/knowledge/research/merged" "{skill_dir}/knowledge/research/reviews"
- ```
-3. 确认采集策略(在 intake 阶段已确定):local-first / web+local / web-only
-4. 按 **6-track 独立文件结构** 写 research notes(不可合并,不可克隆观察):
- - `knowledge/research/raw/01_writings.md`(维度 1:著作与系统思考)
- - `knowledge/research/raw/02_conversations.md`(维度 2:即兴对话与压力应对)
- - `knowledge/research/raw/03_expression_dna.md`(维度 3:语言指纹)
- - `knowledge/research/raw/04_decisions.md`(维度 4:行为与选择)
- - `knowledge/research/raw/05_external_views.md`(维度 5:他者视角与批评)
- - `knowledge/research/raw/06_timeline.md`(维度 6:认知轨迹)
-5. 研究过程必须遵守 **品味原则 + 信源黑名单 + 信源优先级**(见 research prompt),每条 evidence 必须标注 source weight (1-7)。
-6. 合并 research:
- ```bash
- python3 "{distilly_skill_root}/tools/research/merge_research.py" "{skill_dir}"
- ```
-7. 读取 `knowledge/research/merged/summary.md`,确认最低门槛:
- - `Files scanned >= 6`
- - `Unique URLs >= 8`
- - `Primary-source markers >= 3`
- - `Source metadata blocks >= 6`
- - `Contradiction bullets >= 6`
- - `Inference bullets >= 6`
- - `Potential long quote lines = 0`
- - `Track coverage count = 6`
- - research notes 里的 URL 必须是**实际打开过的具体页面**,不是平台首页、搜索页、话题页或占位路径
- 如果不满足,继续补对应 track,而不是直接进入后续 review。
-8. **质量关卡(Phase 1.5)**:在进入 audit 之前,向用户展示结构化采集摘要(含 primary 比例、矛盾数、候选 mental models、known-answer 候选、薄弱维度、冷门人物判定)。等待用户确认后再继续。
-9. 再读取:
- - `prompts/celebrity/budget_unfriendly/audit.md`
- - `prompts/celebrity/budget_unfriendly/synthesis.md`
- - `references/celebrity_budget_unfriendly_template.md`
-10. 先生成 `knowledge/research/reviews/research_audit.md`
- - 审计必须明确给出 `PASS / FAIL`
- - audit 必须检查:信源层级合规(无黑名单)、primary 比例 > 50%、品味原则遵守、冷门人物评估
- - 如果 audit 是 `FAIL`,按 audit 给出的 Backfill Tasks 补齐,不要跳到 synthesis
-11. **提炼关卡(Phase 2.5)**:audit 通过后,向用户展示候选 mental models 摘要(含三重门判定、evidence anchors、failure modes)。确认合理性后再进入 synthesis。
-12. 再生成 `knowledge/research/reviews/synthesis.md`
- - 必须对候选 mental models 做 triple-gate 判断:
- - cross-context recurrence
- - generative power
- - exclusivity
- - 同时提取智识谱系种子(influenced by / diverged from)和 Agentic Protocol 种子(该人物会如何分析新问题的维度列表)
-13. 再按 `prompts/celebrity/budget_unfriendly/validation.md` 生成:
- - `knowledge/research/reviews/validation.md`
- - validation 必须明确给出 `PASS / FAIL`
- - 必须做 known-answer check(至少 2 题)+ edge-case check(1 题)+ voice check(100 字盲测)+ copyright check + Agentic Protocol check
- - 如果 validation 是 `FAIL`,必须先修 draft 再继续
-14. budget-unfriendly 的后续分析输入必须优先使用:
- - 6-track raw notes
- - merged research summary
- - research audit
- - synthesis review(含智识谱系种子、Agentic Protocol 种子)
- - validation review
- - 用户补充材料
-
-两种 celebrity profile 的共同约束:
-
-- 如果外部搜集失败或被平台验证拦截:
- - 明确告诉用户搜集受限的原因
- - 保留已有 research 原始材料和 merged summary
- - 继续生成,但把 `source_grounding` 视为未完成
- - **不要**为了通过质量检查而编造 URL、引用、书名、视频标题,或塞入泛化主页链接
-- **不要**把完整 transcript、完整字幕、长段原文抄进仓库
-- 只允许保留结构化摘要、来源元信息和极短引用,避免版权风险
-
-完成 family 解析后,再按两条线分析:
-
-**线路 A(Work Skill)**:
-- 参考 `prompts/work_analyzer.md`
-- 提取:负责系统、技术规范、工作流程、输出偏好、经验知识
-- celebrity 场景下,`work` 更偏方法论、判断框架、决策习惯,不要机械套成“工作职责”
-
-**线路 B(Persona)**:
-- 使用当前 family 对应的 persona analyzer
-- 如果 `celebrity` 且 `research_profile=budget-unfriendly`,改用:
- - `prompts/celebrity/budget_unfriendly/persona_analyzer.md`
-- 将用户填写的标签翻译为具体行为规则
-- 从原材料中提取:表达风格、决策模式、人际行为
-- celebrity 场景下,必须保留:
- - mental models
- - decision heuristics
- - expression DNA
- - contradictions
- - honest boundaries
-
-### Step 4:生成并预览
-
-使用 `prompts/work_builder.md` 生成 Work 内容。
-使用当前 family 对应的 persona builder 生成 Persona 内容。
-
-具体映射:
-- `colleague` → `prompts/persona_builder.md`
-- `relationship` → `prompts/relationship/persona_builder.md`
-- `celebrity` → `prompts/celebrity/persona_builder.md`
-- `celebrity` + `budget-unfriendly` → `prompts/celebrity/budget_unfriendly/persona_builder.md`
-
-向用户展示摘要(各 5-8 行),询问:
-```
-Work Skill 摘要:
- - 负责:{xxx}
- - 技术栈:{xxx}
- - CR 重点:{xxx}
- ...
-
-Persona 摘要:
- - 核心性格:{xxx}
- - 表达风格:{xxx}
- - 决策模式:{xxx}
- ...
-
-确认生成?还是需要调整?
-```
+5. 品味原则:长文 > 金句,争议 > 共识,变化 > 固定,一手 > 二手。信源黑名单:永不引用知乎、微信公众号、百度百科、内容农场。信源优先级:用户本地材料 > 一手著作 > 长访谈 > 决策记录 > 社交媒体 > 外部分析 > 二手转述。
+6. 合并研究笔记后确认 `Files scanned >= 3`、`Unique URLs >= 2`、`Potential long quote lines = 0`;notes 里的 URL 必须是实际打开过的具体页面,不是平台首页、搜索页、话题页或占位路径。
+7. **质量关卡(Phase 1.5)**:进入分析前向用户展示结构化采集摘要(6 维度来源数 + 关键发现 + 矛盾点 + 薄弱维度 + 冷门人物判定),等用户确认再继续。
+8. **冷门人物检测**:总来源 < 10 条时,心智模型限制为 2–3 个,薄弱模型标"基于有限信息",扩大诚实边界章节,并告诉用户补什么材料能改善质量。
+9. 分析输入优先使用:一手材料(信源权重 1-3)> 合并后的 research summary > 用户补充描述。
-### Step 5:写入文件
-
-用户确认后,不要手工拼接 `skills/colleague/{slug}` 这类文件树。统一走 writer:
-
-1. 先解析当前 storage root:
- - `colleague` → `./skills/colleague`
- - `relationship` → `./skills/relationship`
- - `celebrity` → `./skills/celebrity`
-2. 用 `Write` 工具写三个临时文件:
- - `/tmp/distilly_{slug}_meta.json`
- - `/tmp/distilly_{slug}_work.md`
- - `/tmp/distilly_{slug}_persona.md`
-3. `meta.json` 至少包含:
- - `name`
- - `display_name`
- - `character`
- - `research_profile`(当 character=`celebrity` 时必填)
- - `classification.language`(必须设置为用户当前语言,例如 `zh-CN` 或 `en`)
- - `profile`
- - `tags`
- - `knowledge_sources`
-4. 然后调用:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action create \
- --character {character} \
- --research-profile {research_profile} \
- --slug {slug} \
- --name "{name}" \
- --meta /tmp/distilly_{slug}_meta.json \
- --work /tmp/distilly_{slug}_work.md \
- --persona /tmp/distilly_{slug}_persona.md \
- --base-dir {resolved_base_dir}
- ```
-5. 该命令会统一生成:
- - `SKILL.md`
- - `work.md`
- - `persona.md`
- - `work_skill.md`
- - `persona_skill.md`
- - `manifest.json`
- - `meta.json`
- - 如需把生成后的角色 Skill 安装到宿主:
- - Claude Code:追加 `--install-claude-skill`
- - OpenClaw:追加 `--install-openclaw-skill`
- - Codex:追加 `--install-codex-skill`
- - Hermes:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host hermes --force`;可信项目可追加 `--skills-dir .hermes/skills`,先运行 `hermes skills trust`,然后新建会话或运行 `/reload-skills`。只有已在 Hermes 的 `skills.external_dirs` 中显式配置时,才使用 `~/.agents/skills`
- - DeepSeek Harness:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host deepseek-harness --force`;项目级安装追加 `--skills-dir .dsh/skills`
- - Pi:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host pi --force`;项目级安装追加 `--skills-dir .pi/skills`,调用命令为 `/skill:{character}-{slug}`
- - Grok Build:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host grok-build --force`;项目级安装追加 `--skills-dir .grok/skills`
- - OpenCode:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host opencode --force`;项目级安装追加 `--skills-dir .opencode/skills`
- - 统一安装器只写入自包含的 `SKILL.md` 和安装元数据,会在安装副本中规范旧版 frontmatter;不要手动复制整个生成目录,其中可能包含私有原始材料
- - Claude Code on Windows:可再追加 `--install-claude-command-shim`
-6. 如果当前是 `celebrity`,创建完成后必须再跑一次质量检查:
- ```bash
- python3 "{distilly_skill_root}/tools/research/quality_check.py" "{resolved_base_dir}/{slug}/SKILL.md" --profile {research_profile}
- ```
-7. 如果 `celebrity` 的质量检查仍然提示 `source_grounding` 失败:
- - 可以补写诚实的来源说明和局限说明
- - 但只有在拿到真实、具体、可追溯的外部来源时,才能补充 URL
- - **不要**用站点首页、topic 页、搜索页、个人空间首页等泛化链接来“刷过”检查
- - 如果没有真实来源,就保留 FAIL,并向用户说明后续需要补哪些材料
-
-告知用户时,文件位置必须按当前 family 返回,不要默认写成 colleague。
+### budget-unfriendly
+
+1. 先读 `prompts/celebrity/budget_unfriendly/research.md` 和 `references/celebrity_budget_unfriendly_framework.md`。
+2. 按 **6-track 独立文件结构** 写 research notes(不可合并、不可克隆观察):`01_writings.md` / `02_conversations.md` / `03_expression_dna.md` / `04_decisions.md` / `05_external_views.md` / `06_timeline.md`。
+3. 每条 evidence 必须标注 source weight (1-7);遵守品味原则 + 信源黑名单 + 信源优先级。
+4. 最低门槛:`Files scanned >= 6`、`Unique URLs >= 8`、`Primary-source markers >= 3`、`Source metadata blocks >= 6`、`Contradiction bullets >= 6`、`Inference bullets >= 6`、`Potential long quote lines = 0`、`Track coverage count = 6`。不满足就补对应 track,不要跳到 review。
+5. 依次生成 `knowledge/research/reviews/research_audit.md`(明确 `PASS/FAIL`,检查信源层级、primary 比例 > 50%、品味原则、冷门人物)→ `synthesis.md`(triple gate:cross-context recurrence / generative power / exclusivity;提取智识谱系种子与 Agentic Protocol 种子)→ 按 `prompts/celebrity/budget_unfriendly/validation.md` 生成 `validation.md`(known-answer ≥2 题 + edge-case 1 题 + voice check 100 字盲测 + copyright check + Agentic Protocol check,明确 `PASS/FAIL`)。
+6. 任何 `FAIL` 都先补材料再继续;不要为了通过检查编造 URL、引用、书名或视频标题。
---
@@ -666,30 +296,13 @@ Persona 摘要:
用户提供新文件或文本时:
-1. 按 Step 2 的方式读取新内容
-2. 根据当前 family 解析 base dir
-3. 用 `Read` 读取现有 `{resolved_base_dir}/{slug}/work.md` 和 `persona.md`
-4. 使用当前 family 对应的 merger prompt 分析增量内容
-5. 存档当前版本(用 Bash):
- ```bash
- python3 "{distilly_skill_root}/tools/version_manager.py" \
- --action backup \
- --character {character} \
- --slug {slug} \
- --base-dir {resolved_base_dir}
- ```
-6. 把 work/persona 增量分别写到临时 patch 文件
-7. 调用:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --work-patch /tmp/distilly_{slug}_work_patch.md \
- --persona-patch /tmp/distilly_{slug}_persona_patch.md \
- --base-dir {resolved_base_dir}
- ```
-8. 如果当前是 `celebrity`,更新后再次执行 quality check
+1. 按 Step 1 的 Collect 流程采集新内容(本地文件走 `distilly harvest`,导出走 `distilly parse-chat`,粘贴走 `distilly note --from -`)。
+2. 跑 `distilly retrospect` 刷新派生,再按 Step 3 复述"读了什么、多少条、多少锚点"。
+3. 根据当前 family 解析 base dir,读取现有 `{resolved_base_dir}/{slug}/work.md` 和 `persona.md`。
+4. 使用当前 family 对应的 merger prompt 分析增量内容。
+5. 用 `distilly skill version` 存档当前版本。
+6. 把 work/persona 增量分别写到临时 patch 文件,再走 `distilly skill update`。
+7. 如果当前是 `celebrity`,更新后用 `distilly doctor` 复核证据覆盖率。
---
@@ -697,76 +310,72 @@ Persona 摘要:
用户表达"不对"/"应该是"时:
-1. 参考 `prompts/correction_handler.md` 识别纠正内容
-2. 判断属于 Work(技术/流程)还是 Persona(性格/沟通)
-3. 如果属于 Work:
- - 生成 `/tmp/distilly_{slug}_work_patch.md`
- - patch 必须是可替换的 `##` section,不要直接手改最终文件
- - 调用:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --work-patch /tmp/distilly_{slug}_work_patch.md \
- --base-dir {resolved_base_dir}
- ```
-4. 如果属于 Persona:
- - 将 correction 写入 `/tmp/distilly_{slug}_correction.json`
- - 单条纠正可直接写成 `{scene, wrong, correct}`
- - 多条 persona 纠正可写成 `{"persona_corrections": [{...}, {...}]}`
- - 调用:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --correction-json /tmp/distilly_{slug}_correction.json \
- --base-dir {resolved_base_dir}
- ```
-5. 如果当前是 `celebrity`,更新后再次执行 quality check
-6. 不要直接手改 `work.md`、`persona.md`、`SKILL.md`、`meta.json`;统一通过 writer 更新
+1. 参考 `prompts/correction_handler.md` 识别纠正内容。
+2. 判断属于 Work(技术/流程)还是 Persona(性格/沟通)。
+3. 如果属于 Work:生成可替换 `##` section 的 patch 临时文件,走 `distilly skill update`,不要直接手改 `work.md`。
+4. 如果属于 Persona:把 correction 写成 `{scene, wrong, correct}`(多条写成 `{"persona_corrections": [...]}`)的临时 JSON,走 `distilly skill update`。
+5. 纠正若与现有结论冲突,先向用户展示冲突再决定;纠正内容本身也要带锚点或标注为"用户口述,无锚点"。
+6. 如果当前是 `celebrity`,更新后用 `distilly doctor` 复核。
---
## 管理操作
列出三类 Skill:
+
```bash
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character colleague --base-dir ./skills/colleague
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character relationship --base-dir ./skills/relationship
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character celebrity --base-dir ./skills/celebrity
+distilly skill list
```
回滚某个 Skill 版本:
-```bash
-# colleague
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character colleague --slug {slug} --version {version} --base-dir ./skills/colleague
-# relationship
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character relationship --slug {slug} --version {version} --base-dir ./skills/relationship
-
-# celebrity
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character celebrity --slug {slug} --version {version} --base-dir ./skills/celebrity
+```bash
+distilly skill version
```
-删除某个 Skill:
-确认 character 后执行:
+删除某个 Skill(确认 character 后):
+
```bash
-# colleague
rm -rf skills/colleague/{slug}
-
-# relationship
rm -rf skills/relationship/{slug}
-
-# celebrity
rm -rf skills/celebrity/{slug}
```
+宿主安装:`distilly install `;卸载:`distilly uninstall`。
+
+列出与撤销已授予的同意:
+
+```bash
+distilly consent list
+distilly consent revoke
+```
+
+---
+
+## 必须
+
+- 先列"读了哪些文件、各多少条、多少锚点",再写结论;每条结论带 `文件 + 锚点`。
+- 无证据写 `unknown`;候选不当结论。
+- 每一步都按"五步主线"的完成判据检查产物,再进入下一步。
+
+## 禁止
+
+- 不改写引文,不伪造 URL、锚点、书名、视频标题,不用平台首页刷来源。
+- 密钥只从 `~/.distilly/*_config.json` 或环境变量读,永不写进对话、文件、回执或日志。
+- 不自己拼平台 API 请求:所有网络采集都通过 `distilly collect` / `distilly harvest` / `distilly parse-chat` / `distilly parse-email` / `distilly parse-subtitle` / `distilly parse-doc` / `distilly parse-archive` / `distilly transcribe`。
+- 不静默降级:失败、不可用渠道、没跑的步骤都要说清楚。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
---
---
-# English Version
+## English
# Distilly Creator
@@ -803,716 +412,313 @@ Enter evolution mode when the user says:
Compatibility update alias:
- `/update-colleague {slug}`
-When the user asks to see generated skills, use the list commands in "Management Operations" below.
+When the user asks to see generated skills, use the list command in "Management Operations" below.
---
-## Tool Usage Rules
+## Command Contract (single entrypoint)
-This Skill runs in any compatible host that can read local files and execute Bash / Python commands. Use the following tool conventions:
+Every collection, derivation, and render step goes through `distilly`. Command names match the command table in `docs/v2/CONTRACT.md` §1 word for word; do not invent subcommands or fields.
-| Task | Tool |
-|------|------|
-| Read PDF documents | `Read` tool (native PDF support) |
-| Read image screenshots | `Read` tool (native image support) |
-| Read MD/TXT files | `Read` tool |
-| Parse Lark message JSON export | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_parser.py"` |
-| Lark auto-collect (recommended) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_auto_collector.py"` |
-| Lark docs (browser session) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_browser.py"` |
-| Lark docs (MCP App Token) | `Bash` → `python3 "{distilly_skill_root}/tools/feishu_mcp_client.py"` |
-| DingTalk auto-collect | `Bash` → `python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py"` |
-| Collect public X post candidates | `Bash` → `python3 "{distilly_skill_root}/tools/research/xquik_public_posts.py"` |
-| Parse email .eml/.mbox | `Bash` → `python3 "{distilly_skill_root}/tools/email_parser.py"` |
-| Write/update Skill files | `Write` / `Edit` tool |
-| Version management | `Bash` → `python3 "{distilly_skill_root}/tools/version_manager.py"` |
-| List existing Skills | `Bash` → `python3 "{distilly_skill_root}/tools/skill_writer.py" --action list` |
-
-**Base directories**:
-- `colleague` → `./skills/colleague/{slug}/`
-- `relationship` → `./skills/relationship/{slug}/`
-- `celebrity` → `./skills/celebrity/{slug}/`
-
-For a global path, use `--base-dir` with the storage root for that character family.
-
-The Lark-labelled compatibility collectors currently connect to the China-region `open.feishu.cn` / `feishu.cn` endpoints. International `larksuite.com` tenant routing is not implemented yet.
+| Task | Command |
+|------|---------|
+| Zero-credential: directory/file → `knowledge/` | `distilly harvest ` |
+| Parse ChatGPT / Claude / Slack / Telegram / Discord exports | `distilly parse-chat ` |
+| Parse email | `distilly parse-email ` |
+| Parse subtitles | `distilly parse-subtitle ` |
+| Parse documents | `distilly parse-doc ` |
+| Parse archives (X archive / Takeout / social exports) | `distilly parse-archive ` |
+| Pure derivation → `evidence/derived/*.json` | `distilly retrospect` |
+| Collection on channels needing key / OAuth | `distilly collect ` |
+| Browser computer use (consent token required) | `distilly collect x --mode browser --consent ` |
+| Audio/video transcription (optional backend) | `distilly transcribe ` |
+| Register what the model itself read | `distilly note --from ` |
+| Consent management | `distilly consent ` |
+| View check and render | `distilly view check`; `distilly view render [--shareable]` |
+| Evidence health check | `distilly doctor` |
+| Create/update/list/version a generated Skill | `distilly skill ` |
+| Host install | `distilly install `; `distilly uninstall` |
+
+- Every subcommand supports a `--json` receipt; `--help` has a Chinese and an English section.
+- For channels needing a key or OAuth: first tell the user which channel will be read and what it yields, and only run `distilly collect` after they agree.
+- Computer-use commands require `--consent `; without a token the command ends with `exit 2` and its receipt says it is waiting for user consent.
+- Credentials are read only from `~/.distilly/*_config.json` or environment variables; receipts, logs, and chat only ever contain the config file name, never a value.
+
+### Migration table (all legacy forms are deprecated)
+
+| Legacy form (deprecated) | New form |
+|--------------------------|----------|
+| `tools/feishu_parser.py` (deprecated) | `distilly parse-chat` |
+| `tools/feishu_auto_collector.py` (deprecated) | `distilly collect feishu` |
+| `tools/feishu_browser.py` (deprecated) | `distilly collect feishu --mode browser --consent ` |
+| `tools/feishu_mcp_client.py` (deprecated) | `distilly collect feishu` |
+| `tools/dingtalk_auto_collector.py` (deprecated) | `distilly collect dingtalk` |
+| `tools/email_parser.py` (deprecated) | `distilly parse-email` |
+| `tools/research/xquik_public_posts.py` (deprecated) | `distilly collect x` |
+| `tools/research/transcribe_audio.py` (deprecated) | `distilly transcribe` |
+| `tools/research/srt_to_transcript.py` (deprecated) | `distilly parse-subtitle` |
+| `tools/skill_writer.py` (deprecated) | `distilly skill create` / `distilly skill update` / `distilly skill list` |
+| `tools/version_manager.py` (deprecated) | `distilly skill version` |
+| `tools/install_generated_skill.py` (deprecated) | `distilly install ` |
+| `tools/research/quality_check.py` (deprecated) | `distilly doctor` |
+| `tools/research/merge_research.py` (deprecated) | No contract replacement yet: remains derivation only, use `distilly retrospect`; merging research notes is a known gap |
+| `tools/research/download_subtitles.sh` (deprecated) | No contract replacement yet: ask the user for a local subtitle file, then use `distilly parse-subtitle` |
+
+Both forms may coexist during migration, but the new form wins; whenever a Python tool is still referenced, keep the `deprecated` marker.
---
-## Main Flow: Create a New Skill
+## Disk Contract (what the model may write)
-### Step 0: Confirm the character family
+```
+skills///
+ SKILL.md work.md persona.md work_skill.md persona_skill.md manifest.json meta.json
+ knowledge/{docs,messages,emails}/
+ knowledge/raw//... # raw bytes, append-only
+ knowledge/text/.md # normalized text, paragraph anchors [k0012] / [k0012:t3]
+ knowledge/index.json # ledger {id,kind,origin,fetched_at,bytes,sha256,credentialed,method,warnings[]}
+ evidence/derived/*.json # retrospect output, every conclusion carries evidence anchors
+ views/.view.json # the model writes only sections/order/emphasis (no facts)
+ views/.html # render output: single file, offline, dual theme
+ evidence/renders/receipt.json # render receipt (sha256 + bytes + inlined sources)
+```
-If the user entered `/distilly`, first confirm which family should be distilled:
+- The model may write: `views/.view.json` (sections, order, emphasis only), temporary working files, and sources registered through `distilly note --from ` with `method:"model-read"`.
+- The model must not write: `knowledge/raw/**` (raw bytes, append-only), `knowledge/index.json`, `evidence/derived/*.json` (produced by `distilly retrospect`), `evidence/renders/receipt.json`.
+- Screenshots, receipts, and diff images are never committed (`.gitignore` already covers `dst-evidence/`); local artifacts live in `/tmp/dst-evidence//`.
+- Anchor format is always `[k00NN]` (4-digit zero-padded) or `[k00NN:tM]` (with turn index). Every conclusion carries `file + anchor`; with no evidence, write `unknown`.
-1. `colleague`
-2. `relationship`
-3. `celebrity`
+---
-If the host already passed an explicit family, lock the character family immediately.
+## Five-Step Mainline
-If the current family is `celebrity`, also confirm the research profile:
+Creation, append, and correction all follow one mainline: **Collect → Derive → Read → Distill → Render**.
-1. `budget-friendly`
-2. `budget-unfriendly`
-
-Default to `budget-friendly`. Only switch to `budget-unfriendly` when the user explicitly wants deeper research, higher confidence, or accepts a slower and more expensive distillation pass.
+| Step | Required artifacts | Count criteria | Where sha256 comes from | What to do on failure |
+|------|--------------------|----------------|-------------------------|-----------------------|
+| 1 Collect | `knowledge/raw//**`, `knowledge/text/.md`, `knowledge/index.json` | 1 ledger entry per grounded source; ≥1 anchor in every text file | `outputs[].sha256` of the `distilly --json` receipt, byte-identical to `sha256` in `knowledge/index.json` | Non-zero exit: record command, stderr, remedy; with 0 grounded sources stop and do not enter Derive |
+| 2 Derive | `evidence/derived/*.json` | Every derived conclusion carries evidence anchors; two runs are byte-identical | Receipt `outputs[].sha256`; identical sha256 across two runs | Non-zero exit: first repair `knowledge/index.json` integrity; never hand-write derived JSON |
+| 3 Read | No new files; produce a restatement of what was read | Per file: file → rows → anchors | Quote `sha256` from `knowledge/index.json`; never compute your own | Missing files or zero anchors: go back to Step 1; never write conclusions from memory |
+| 4 Distill | `work.md`, `persona.md`, plus celebrity research/audit/synthesis/validation | Every dimension has anchors or `unknown`; celebrity has an explicit `PASS/FAIL` | Quote the sha256 of cited sources from the ledger | Thin evidence: mark `(insufficient source material)` / candidate and say what material is missing |
+| 5 Render | `views/.view.json`, `views/.html`, `evidence/renders/receipt.json` | Receipt sha256 matches the actual html sha256; `distilly doctor` reports the anchor back-reference rate | sha256 in `evidence/renders/receipt.json` | Render failure: keep view.json, do not publish, report the error |
-### Step 1: Basic Info Collection
+No step may degrade silently: either fix it, or state the failure in the user-facing report and in the receipt's `warnings[]` / `unavailable[]`.
-Choose the intake prompt by character family:
+### Step 1: Collect
-- `colleague` → `prompts/intake.md`
-- `relationship` → `prompts/relationship/intake.md`
-- `celebrity` → `prompts/celebrity/intake.md`
+1. Read `prompts/collectors.md` first and pick the route from its "which command when" table.
+2. Zero-credential sources (local files, export bundles, subtitles, documents, archives) go straight to `distilly harvest`, `distilly parse-chat`, `distilly parse-email`, `distilly parse-subtitle`, `distilly parse-doc`, `distilly parse-archive`.
+3. Channels needing a key or OAuth (Feishu, Slack, DingTalk, X, Discord, Reddit, Notion, Gmail) require the user's consent first, then `distilly collect `; manage consent scope with `distilly consent `.
+4. Browser computer use must follow `prompts/computer-use.md`: ask before acting, read-only whitelist, default ≤20 screens / ≤10 minutes / ≤6 scrolls per minute, every screen persisted with raw text + URL + timestamp + screenshot (screenshots stay local), interruptible; `distilly collect x --mode browser --consent ` must exit without a token — never work around it.
+5. When the user can only paste text or screenshots, register the source with `distilly note --from ` (`method:"model-read"`); never pretend it was collected.
+6. Transcribe audio/video with `distilly transcribe` before parsing subtitles; never commit a full transcript to the repository.
-For `colleague` and `relationship`, ask only 3 questions.
-For `celebrity`, use the 4-question intake in `prompts/celebrity/intake.md`; the fourth question must confirm `research_profile`.
+**Completion criteria**: `knowledge/index.json` has one ledger entry per grounded source (with `id`, `kind`, `origin`, `fetched_at`, `bytes`, `sha256`, `credentialed`, `method`, `warnings[]`); every `knowledge/text/.md` has at least one anchor; receipt `inputs[]`/`outputs[]` sha256 matches the ledger; unavailable channels appear in `unavailable[]`.
+**On failure**: when a command exits non-zero, report the exact command, its stderr, and the remedy (for example which `~/.distilly/*_config.json` must be configured), then stop and wait for instructions; if 0 sources landed, do not enter Step 2.
-The default 3 base questions are:
+### Step 1.5: Corpus shape check (before Derive)
-1. **Alias / Codename** (required)
-2. **Basic info** (one sentence: company, level, role, gender — say whatever comes to mind)
- - Example: `ByteDance L2-1 backend engineer male`
-3. **Personality profile** (one sentence: MBTI, zodiac, traits, corporate culture, impressions)
- - Example: `INTJ Capricorn blame-shifter ByteDance-style strict in CR but never explains why`
+1. Once collection is done, run `distilly doctor --require-shape` and read `shape[]` in the receipt.
+2. On `shape[].verdict === "FAIL"` **stop**, quote `shape[].reasons[]` to the user, and say what to add
+ (typically: multi-speaker material with only 3% of units attributable — ask for that person's own
+ output: their interviews/talks, their writing, their mail, not a meeting stream).
+3. The thresholds live in `shape[].reasons[]`, so nothing has to be memorised: fewer than 20 citable
+ units → FAIL; multi-speaker material with under 40% attributable units, or a top speaker under 20%
+ → FAIL. Missing speaker labels is **not** a failure (a person's own writing or a solo recording is fine).
+4. Only continue to Step 2 on `PASS`.
-Everything except the alias can be skipped. Summarize and confirm before moving to the next step.
+**Why this step exists**: without asking "can this material carry a person at all", the run goes all the
+way to Step 4 and produces a portrait of a room instead of a portrait of a person. That happened once.
-### Step 2: Source Material Import
+### Step 2: Derive
-Ask the user how they'd like to provide materials:
+1. Do not read `evidence/derived/*` before deriving — run `distilly retrospect` first.
+2. `distilly retrospect` is pure derivation: input is `knowledge/**`, output is `evidence/derived/*.json`, and every conclusion carries evidence anchors.
+3. To prove determinism, run it twice; the same input must produce identical sha256.
-```
-How would you like to provide source materials?
+**Completion criteria**: `evidence/derived/*.json` exists; the receipt reports `anchors.total` / `anchors.cited`; `outputs[].sha256` is identical across two runs.
+**On failure**: a non-zero exit means the input side is broken — go back to Step 1 and check the ledger and text anchors; never hand-write or hand-edit derived JSON to force a pass.
- [A] Lark Auto-Collect (recommended)
- Enter name, auto-pull messages + docs + spreadsheets
+### Step 3: Read
- [B] DingTalk Auto-Collect
- Enter name, auto-pull docs + spreadsheets
- Messages collected via browser (DingTalk API doesn't support message history)
+1. Read in this order: `knowledge/index.json` → `knowledge/text/*.md` → `evidence/derived/*.json`.
+2. Restate to the user "which files were read, how many rows each, how many anchors" before writing conclusions.
+3. Every conclusion carries `file + anchor` (for example `knowledge/text/feishu.md [k0042]`).
+4. Conclusions without evidence are written as `unknown`, together with what material would supply the evidence.
+5. Keep facts and candidates apart: only statements backed by a specific anchor are facts; patterns, tendencies, and inferences from derived files stay candidates and never get promoted to conclusions.
+6. Full detail rules are in `prompts/retrospection.md`.
- [C] Lark Link
- Provide doc/Wiki link (browser session or MCP)
+**Completion criteria**: every file in the restatement list back-references into the ledger; every cited anchor really exists in `knowledge/text/**`; no conclusion is left without an anchor.
+**On failure**: with missing files or zero anchors, go back to Step 1; never fill the gap from memory or general knowledge.
- [D] Upload Files
- PDF / images / exported JSON / email .eml
+### Step 4: Distill
- [E] Paste Text
- Copy-paste text directly
+Resolve the execution matrix for the family confirmed in Step 0:
-Can mix and match, or skip entirely (generate from manual info only).
-```
+| character | intake | persona analyzer | persona builder | merger | storage root |
+|-----------|--------|------------------|-----------------|--------|--------------|
+| `colleague` | `prompts/intake.md` | `prompts/persona_analyzer.md` | `prompts/persona_builder.md` | `prompts/merger.md` | `./skills/colleague/{slug}` |
+| `relationship` | `prompts/relationship/intake.md` | `prompts/relationship/persona_analyzer.md` | `prompts/relationship/persona_builder.md` | `prompts/relationship/merger.md` | `./skills/relationship/{slug}` |
+| `celebrity` | `prompts/celebrity/intake.md` | `prompts/celebrity/persona_analyzer.md` | `prompts/celebrity/persona_builder.md` | `prompts/celebrity/merger.md` | `./skills/celebrity/{slug}` |
----
+Shared across all families: Work analyzer `prompts/work_analyzer.md`, Work builder `prompts/work_builder.md`, Correction handler `prompts/correction_handler.md`.
-#### Option A: Lark Auto-Collect (Recommended)
+Two tracks:
-First-time setup:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" --setup
-```
+- **Track A (Work Skill)**: follow `prompts/work_analyzer.md` and extract responsible systems, technical standards, workflow, output preferences, experience. For `celebrity`, interpret `work` as methods, judgment frameworks, and decision patterns.
+- **Track B (Persona)**: use the family-specific persona analyzer; for `celebrity` with `research_profile=budget-unfriendly`, switch to `prompts/celebrity/budget_unfriendly/persona_analyzer.md`. Translate the user's tags into concrete behavior rules and extract communication style, decision patterns, and interpersonal behavior from the material.
-**Group chat collection** (uses tenant_access_token, bot must be in the group):
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 1000 \
- --doc-limit 20
-```
+Never hand-build a `skills/{family}/{slug}` tree: write `meta.json` / `work.md` / `persona.md` to temporary files and call `distilly skill create` (or `distilly skill update`). Install a generated person Skill with `distilly install `.
-**Private chat (P2P) collection** (requires user_access_token + p2p chat_id):
-
-Private messages can only be accessed via user identity (user_access_token). App identity cannot access private chats.
-
-**Prerequisites**:
-
-The user needs to provide:
-1. **Lark app credentials**: `app_id` and `app_secret` (from the Open Platform)
-2. **User scopes**: The app must have these user scopes enabled:
- - `im:message` — read/send messages as user
- - `im:chat` — read chat list as user
-3. **OAuth authorization code**: obtained after user completes OAuth in browser
-
-If the user is missing any of these, guide them through setup. Don't assume anything is pre-configured.
-
-**Getting user_access_token**:
-
-Once the user provides app_id, app_secret, and confirms scopes are enabled:
-
-1. Generate the OAuth URL for them:
- ```
- https://open.feishu.cn/open-apis/authen/v1/authorize?app_id={APP_ID}&redirect_uri=http://www.example.com&scope=im:message%20im:chat
- ```
- > ⚠️ The redirect_uri must be added in the app's "Security Settings → Redirect URLs"
-
-2. User opens URL, logs in, authorizes
-3. Page redirects to `http://www.example.com?code=xxx`, user copies the code
-4. Exchange code for token:
- ```bash
- python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" --exchange-code {CODE}
- ```
- Or write a Python script to call the same API directly:
- ```python
- # 1. Get app_access_token
- POST https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal
- Body: {"app_id": "xxx", "app_secret": "xxx"}
-
- # 2. Exchange code for user_access_token
- POST https://open.feishu.cn/open-apis/authen/v1/oidc/access_token
- Header: Authorization: Bearer {app_access_token}
- Body: {"grant_type": "authorization_code", "code": "xxx"}
- ```
-
-**Getting the p2p chat_id**:
-
-Users typically don't know their chat_id. When the user has a user_access_token but no chat_id, **write a Python script yourself** to obtain it:
-
-- **Method**: Send a message to the other user's open_id — the response includes the chat_id
- ```python
- POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id
- Header: Authorization: Bearer {user_access_token}
- Body: {"receive_id": "{target_open_id}", "msg_type": "text", "content": "{\"text\":\"hello\"}"}
- # The chat_id in the response is the p2p chat ID
- ```
-- **Important**: `GET /im/v1/chats` does NOT return p2p chats — this is an API limitation, not a permission issue. Do not try to use it for finding private chats.
-- If the user doesn't know the target's open_id, use tenant_access_token to search contacts:
- ```python
- GET https://open.feishu.cn/open-apis/contact/v3/scopes
- # Returns open_ids of all users visible to the app
- ```
-
-**Running collection**:
-
-Once you have user_access_token and chat_id:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_auto_collector.py" \
- --open-id {target_open_id} \
- --p2p-chat-id {chat_id} \
- --user-token {user_access_token} \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 1000
-```
+**Completion criteria**: every dimension has anchors or an explicit `(insufficient source material)`; every behavior rule is concrete and executable; celebrity audit / validation returns an explicit `PASS` or `FAIL`; `distilly doctor` can report evidence coverage, unavailable channels, and the anchor back-reference rate. Celebrity research thresholds are in the subflow below.
+**On failure**: mark thin dimensions `(insufficient source material, add related documents)` and downgrade them to candidates; when `source_grounding` fails, keep the `FAIL` and explain what is missing instead of padding with generic links.
-**Flexibility principle**: The above API calls don't have to go through the collector script. If the script doesn't work or doesn't fit the scenario, write Python scripts directly against the same endpoints. Key API reference:
-- Get token: `POST /auth/v3/app_access_token/internal`, `POST /authen/v1/oidc/access_token`
-- Send message (get chat_id): `POST /im/v1/messages?receive_id_type=open_id`
-- Fetch messages: `GET /im/v1/messages?container_id_type=chat&container_id={chat_id}`
-- Search contacts: `GET /contact/v3/scopes`, `GET /contact/v3/users/{user_id}`
-
-Auto-collected content:
-- Group chats: messages sent by them (system messages and stickers filtered)
-- Private chats: full conversation with both parties (for context understanding)
-- Lark docs and Wikis they created/edited
-- Related spreadsheets (if accessible)
-
-After collection, `Read` the output files:
-- `knowledge/{slug}/messages.txt` → messages (group + private)
-- `knowledge/{slug}/docs.txt` → document content
-- `knowledge/{slug}/collection_summary.json` → collection summary
-
-If collection fails, diagnose the error and attempt to fix it. Common issues:
-- Group chat: bot not added to the group
-- Private chat: user_access_token expired (2-hour TTL, refresh with refresh_token)
-- Insufficient permissions: guide user to enable scopes and re-authorize
-- Or switch to Option B/C
+### Step 5: Render
----
+1. Run `distilly view check` first to confirm every anchor back-references to `knowledge/index.json`.
+2. Write `views/.view.json`: sections, order, and emphasis only — no facts.
+3. `distilly view render` produces the single-file, offline, dual-theme `views/.html` and writes `evidence/renders/receipt.json` (sha256 + bytes + inlined sources).
+4. Use `distilly view render --shareable` only for external sharing, and confirm with the user first.
+5. Re-check evidence coverage, unavailable channels, anchor back-reference rate, and computer-use share with `distilly doctor`.
-#### Option B: DingTalk Auto-Collect
+**Completion criteria**: both `views/.html` and `evidence/renders/receipt.json` exist; the receipt sha256 matches the actual html sha256; zero broken internal links.
+**On failure**: when rendering fails, keep `views/.view.json`, do not publish the HTML, and report the error and the missing sources to the user.
-First-time setup:
-```bash
-python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py" --setup
-```
+### Step 0 (prerequisite): Confirm the family and run intake
-Then enter the name:
-```bash
-python3 "{distilly_skill_root}/tools/dingtalk_auto_collector.py" \
- --name "{name}" \
- --output-dir ./knowledge/{slug} \
- --msg-limit 500 \
- --doc-limit 20 \
- --show-browser # add this flag on first use to complete DingTalk login
-```
+If the user entered `/distilly`, first confirm which family should be distilled:
-Collected content:
-- DingTalk docs and knowledge bases they created/edited
-- Spreadsheets
-- Messages (⚠️ DingTalk API doesn't support message history — auto-switches to browser scraping)
+1. `colleague`
+2. `relationship`
+3. `celebrity`
-After collection, `Read`:
-- `knowledge/{slug}/docs.txt`
-- `knowledge/{slug}/bitables.txt`
-- `knowledge/{slug}/messages.txt`
+If the host already passed an explicit family, lock the character family immediately.
-If message collection fails, prompt user to upload chat screenshots.
+If the current family is `celebrity`, also confirm the research profile:
----
+1. `budget-friendly`
+2. `budget-unfriendly`
-#### Option D: Upload Files
-
-- **PDF / Images**: `Read` tool directly
-- **Lark message JSON export**:
- ```bash
- python3 "{distilly_skill_root}/tools/feishu_parser.py" --file {path} --target "{name}" --output /tmp/feishu_out.txt
- ```
- Then `Read /tmp/feishu_out.txt`
-- **Email files .eml / .mbox**:
- ```bash
- python3 "{distilly_skill_root}/tools/email_parser.py" --file {path} --target "{name}" --output /tmp/email_out.txt
- ```
- Then `Read /tmp/email_out.txt`
-- **Markdown / TXT**: `Read` tool directly
+Default to `budget-friendly`. Only switch to `budget-unfriendly` when the user explicitly wants deeper research, higher confidence, or accepts a slower and more expensive distillation pass.
----
+Choose the intake prompt by family: `colleague` → `prompts/intake.md`; `relationship` → `prompts/relationship/intake.md`; `celebrity` → `prompts/celebrity/intake.md`. `colleague` and `relationship` ask only 3 questions; `celebrity` asks 4, and the fourth must confirm `research_profile`.
-#### Option C: Lark Link
+The default 3 base questions:
-When the user provides a Lark doc/Wiki link, ask which method to use:
+1. **Alias / Codename** (required)
+2. **Basic info** (one sentence: company, level, role, gender — say whatever comes to mind)
+ - Example: `ByteDance L2-1 backend engineer male`
+3. **Personality profile** (one sentence: MBTI, zodiac, traits, corporate culture, impressions)
+ - Example: `INTJ Capricorn blame-shifter ByteDance-style strict in CR but never explains why`
-```
-Lark link detected. Choose read method:
-
- [1] Browser Method (recommended)
- Reuses your local Chrome login session
- ✅ Works with internal docs requiring permissions
- ✅ No token configuration needed
- ⚠️ Requires Chrome + playwright installed locally
-
- [2] MCP Method
- Uses a Lark App Token via the official API
- ✅ Stable, no browser dependency
- ✅ Can read messages (needs chat ID)
- ⚠️ Requires App ID / App Secret setup
- ⚠️ Internal docs need admin authorization for the app
-
-Choose [1/2]:
-```
+Everything except the alias can be skipped. Summarize and confirm before entering Collect.
-**Option 1 (Browser)**:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_browser.py" \
- --url "{feishu_url}" \
- --target "{name}" \
- --output /tmp/feishu_doc_out.txt
-```
-First use will open a browser window for login (one-time).
+---
-**Option 2 (MCP)**:
+## Celebrity research subflow (between Step 2 and Step 3)
-First-time setup:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" --setup
-```
+### budget-friendly
-Then read directly:
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" \
- --url "{feishu_url}" \
- --output /tmp/feishu_doc_out.txt
-```
+1. Read `prompts/celebrity/research.md` and follow its **6-dimension parallel collection strategy**.
+2. Collection strategy (fixed during intake): **Local-first** (analyze local material first, search only the gaps) / **Web + local** (full 6-dimension web research cross-validated with local material) / **Web-only**.
+3. For video or podcasts: `distilly transcribe ` first, then `distilly parse-subtitle`; never commit a full transcript.
+4. Split the raw research notes across **at least 3 files** (2 dimensions each), never one monolithic `research_notes.md`:
+ - `knowledge/research/raw/01_core_profile.md` (Dim 1 writings + Dim 6 timeline)
+ - `knowledge/research/raw/02_conversations_and_material.md` (Dim 2 conversations + Dim 4 decisions)
+ - `knowledge/research/raw/03_expression_and_reception.md` (Dim 3 expression DNA + Dim 5 external views)
+5. Taste principles: long-form > snippets, controversy > consensus, change > fixity, firsthand > secondhand. Source blacklist: never cite Zhihu, WeChat official accounts, Baidu Baike, content farms. Source hierarchy: user local material > first-person works > long interviews > decision records > social media > external analysis > secondhand summaries.
+6. Confirm `Files scanned >= 3`, `Unique URLs >= 2`, `Potential long quote lines = 0`; every URL must be a specific page actually opened, not a platform root, search page, topic page, or placeholder.
+7. **Quality checkpoint (Phase 1.5)**: show the user a structured collection summary (sources per dimension + key findings + contradictions + thin dimensions + cold-figure verdict) and wait for confirmation.
+8. **Cold figure detection**: below 10 total sources, limit mental models to 2–3, mark thin models "based on limited information", expand the honest boundaries section, and tell the user what material would improve quality.
+9. Analysis input priority: primary material (source weight 1-3) > merged research summary > explicit user notes.
-Read messages (needs chat ID, format `oc_xxx`):
-```bash
-python3 "{distilly_skill_root}/tools/feishu_mcp_client.py" \
- --chat-id "oc_xxx" \
- --target "{name}" \
- --limit 500 \
- --output /tmp/feishu_msg_out.txt
-```
+### budget-unfriendly
-Both methods output to files, then use `Read` to load results into analysis.
+1. Read `prompts/celebrity/budget_unfriendly/research.md` and `references/celebrity_budget_unfriendly_framework.md` first.
+2. Write the **six-track research set** as independent files (never merged, never cloned): `01_writings.md` / `02_conversations.md` / `03_expression_dna.md` / `04_decisions.md` / `05_external_views.md` / `06_timeline.md`.
+3. Every evidence item carries a source weight (1-7); follow taste principles + source blacklist + source hierarchy.
+4. Minimum floor: `Files scanned >= 6`, `Unique URLs >= 8`, `Primary-source markers >= 3`, `Source metadata blocks >= 6`, `Contradiction bullets >= 6`, `Inference bullets >= 6`, `Potential long quote lines = 0`, `Track coverage count = 6`. If short, fill the weak track instead of skipping to review.
+5. Write, in order: `knowledge/research/reviews/research_audit.md` (explicit `PASS/FAIL`; checks source hierarchy, primary ratio > 50%, taste principles, cold figure) → `synthesis.md` (triple gate: cross-context recurrence / generative power / exclusivity; extract intellectual genealogy and Agentic Protocol seeds) → `validation.md` per `prompts/celebrity/budget_unfriendly/validation.md` (known-answer ≥2 questions + 1 edge case + 100-word voice check + copyright check + Agentic Protocol check, explicit `PASS/FAIL`).
+6. Any `FAIL` means backfill first; never invent URLs, quotes, book titles, or video titles to pass a check.
---
-#### Option E: Paste Text
+## Evolution Mode: Append Files
+
+When the user provides new files or text:
-User-pasted content is used directly as text material. No tools needed.
+1. Collect the new material with the Step 1 flow (`distilly harvest` for local files, `distilly parse-chat` for exports, `distilly note --from -` for pasted text).
+2. Run `distilly retrospect` to refresh derivations, then restate "what was read, how many rows, how many anchors" per Step 3.
+3. Resolve the base dir for the current family and read the existing `{resolved_base_dir}/{slug}/work.md` and `persona.md`.
+4. Analyze the delta with the family-specific merger prompt.
+5. Archive the current version with `distilly skill version`.
+6. Write the work/persona deltas to temporary patch files and apply them with `distilly skill update`.
+7. For `celebrity`, re-check evidence coverage with `distilly doctor` after the update.
---
-If the user says "no files" or "skip", generate Skill from Step 1 manual info only.
-
-### Step 3: Analyze Source Material
+## Evolution Mode: Conversation Correction
-First resolve the execution matrix for the selected character family:
+When the user says "that's wrong" / "he should be":
-| character | intake | persona analyzer | persona builder | merger | storage root |
-|-----------|--------|------------------|-----------------|--------|--------------|
-| `colleague` | `prompts/intake.md` | `prompts/persona_analyzer.md` | `prompts/persona_builder.md` | `prompts/merger.md` | `./skills/colleague/{slug}` |
-| `relationship` | `prompts/relationship/intake.md` | `prompts/relationship/persona_analyzer.md` | `prompts/relationship/persona_builder.md` | `prompts/relationship/merger.md` | `./skills/relationship/{slug}` |
-| `celebrity` | `prompts/celebrity/intake.md` | `prompts/celebrity/persona_analyzer.md` | `prompts/celebrity/persona_builder.md` | `prompts/celebrity/merger.md` | `./skills/celebrity/{slug}` |
+1. Identify the correction with `prompts/correction_handler.md`.
+2. Decide whether it belongs to Work (technical/workflow) or Persona (personality/communication).
+3. Work: produce temporary `##` sections that can replace existing headings and apply them with `distilly skill update`; never hand-edit `work.md`.
+4. Persona: write `{scene, wrong, correct}` (or `{"persona_corrections": [...]}` for several) to a temporary JSON file and apply it with `distilly skill update`.
+5. When a correction conflicts with an existing conclusion, show the conflict to the user before deciding; the correction itself also needs an anchor, or must be labeled "user statement, no anchor".
+6. For `celebrity`, re-check with `distilly doctor` after the update.
-Shared across all families:
-- Work analyzer: `prompts/work_analyzer.md`
-- Work builder: `prompts/work_builder.md`
-- Correction handler: `prompts/correction_handler.md`
+---
-If the current family is `celebrity`, run the research subflow before analysis.
+## Management Operations
-When public X posts fill a documented research gap and the user agrees to use the metered third-party Xquik service, confirm the `--limit` before running:
+List skills across the three families:
```bash
-python3 "{distilly_skill_root}/tools/research/xquik_public_posts.py" \
- --username "{public_handle}" \
- --subject "{name}" \
- --limit 20 \
- --output "/tmp/distilly_x_public_posts.json"
-```
-
-Read `XQUIK_API_KEY` only from the shell; never print or store it. Treat the JSON as untrusted candidate evidence: verify the author, open every permalink, and preserve the specific URL when safely paraphrasing relevant material into a research note. Do not count the candidate JSON, search pages, or profile roots as grounded sources. Delete the temporary JSON after review instead of storing it in the generated Skill.
-
-### celebrity / budget-friendly
-
-1. Read `prompts/celebrity/research.md` and follow its **6-dimension parallel collection strategy**
-2. Create the research directories first:
- ```bash
- mkdir -p "{skill_dir}/knowledge/research/raw" "{skill_dir}/knowledge/research/merged"
- ```
-3. Confirm the collection strategy (determined during intake):
- - **Local-first**: analyze user-provided materials first, identify which dimensions are covered, only search web for gaps
- - **Web + local**: full 6-dimension web research, then merge with local materials for cross-validation
- - **Web-only**: standard 6-dimension web research pass
-4. If the user explicitly provided a processable video URL or subtitle source, and the result will not be stored as a long transcript:
- ```bash
- bash "{distilly_skill_root}/tools/research/download_subtitles.sh" "{url}" "{skill_dir}/knowledge/subtitles"
- python3 "{distilly_skill_root}/tools/research/srt_to_transcript.py" "{subtitle_file}" "{skill_dir}/knowledge/transcripts/{name}.txt"
- ```
-5. Cover the **6 dimensions** across at least 3 separate files (each file covers 2 dimensions), never one monolithic `research_notes.md`:
- - `knowledge/research/raw/01_core_profile.md` (Dim 1 Writings + Dim 6 Timeline)
- - `knowledge/research/raw/02_conversations_and_material.md` (Dim 2 Conversations + Dim 4 Decisions)
- - `knowledge/research/raw/03_expression_and_reception.md` (Dim 3 Expression DNA + Dim 5 External Views)
-6. Research must follow **taste principles** (see research prompt):
- - Long-form > snippets, controversy > consensus, change > fixity, firsthand > secondhand
- - **Source blacklist** — never cite: Zhihu, WeChat official accounts, Baidu Baike, content farms, AI-generated bios
- - **Source hierarchy**: user local materials > first-person works > long interviews > decision records > short-form firsthand > external analysis > secondhand summaries
-7. Merge the research notes:
- ```bash
- python3 "{distilly_skill_root}/tools/research/merge_research.py" "{skill_dir}"
- ```
- Output: `knowledge/research/merged/summary.md`
-8. Read `knowledge/research/merged/summary.md` and confirm:
- - `Files scanned >= 3`
- - `Unique URLs >= 2`
- - `Potential long quote lines = 0`
- - URLs in notes are actual inspected pages, not platform roots, search/topic pages, or placeholder paths
- If these do not hold, extend the research notes before continuing or explicitly record the collection limits.
-9. **Quality checkpoint (Phase 1.5)**: before entering analysis, show the user a structured collection summary:
- ```
- ┌──────────────────────────────┬──────────┬─────────────────────────────┐
- │ Dimension │ Sources │ Key Finding │
- ├──────────────────────────────┼──────────┼─────────────────────────────┤
- │ 1 Writings │ N │ [core thesis / gap] │
- │ 2 Conversations │ N │ [key pattern / gap] │
- │ 3 Expression DNA │ N │ [style marker / gap] │
- │ 4 Decisions │ N │ [decision pattern / gap] │
- │ 5 External Views │ N │ [outside view / gap] │
- │ 6 Timeline │ N │ [trajectory / gap] │
- ├──────────────────────────────┼──────────┼─────────────────────────────┤
- │ Contradictions │ N │ [summary] │
- │ Thin dimensions │ [list] │ Backfill plan: [plan] │
- │ Cold figure? │ yes/no │ │
- └──────────────────────────────┴──────────┴─────────────────────────────┘
- ```
- Wait for user confirmation before continuing. If the user flags issues or wants more depth, extend research first.
-10. **Cold figure detection**: if total sources < 10, apply the cold figure protocol:
- - Limit mental models to 2–3
- - Mark thin models as "based on limited information"
- - Expand the honest boundaries section
- - Tell the user what additional material would improve quality
-11. Celebrity analysis must prioritize:
- - primary materials (source weight 1-3)
- - merged research summary
- - explicit user notes
-
-### celebrity / budget-unfriendly
-
-1. First read:
- - `prompts/celebrity/budget_unfriendly/research.md`
- - `references/celebrity_budget_unfriendly_framework.md`
-2. Create the research directories first:
- ```bash
- mkdir -p "{skill_dir}/knowledge/research/raw" "{skill_dir}/knowledge/research/merged" "{skill_dir}/knowledge/research/reviews"
- ```
-3. Confirm the collection strategy (determined during intake): local-first / web+local / web-only
-4. Build the **six-track research set** as independent files (never merged, never clone observations):
- - `knowledge/research/raw/01_writings.md` (Dim 1: Writings / systematic thought)
- - `knowledge/research/raw/02_conversations.md` (Dim 2: Conversations under pressure)
- - `knowledge/research/raw/03_expression_dna.md` (Dim 3: Linguistic fingerprint)
- - `knowledge/research/raw/04_decisions.md` (Dim 4: Behavior and choices)
- - `knowledge/research/raw/05_external_views.md` (Dim 5: External views and criticism)
- - `knowledge/research/raw/06_timeline.md` (Dim 6: Cognitive trajectory)
-5. Research must follow **taste principles + source blacklist + source hierarchy** (see research prompt). Every evidence item must carry a source weight (1-7) annotation.
-6. Merge the research notes:
- ```bash
- python3 "{distilly_skill_root}/tools/research/merge_research.py" "{skill_dir}"
- ```
-7. Read `knowledge/research/merged/summary.md` and confirm the minimum floor:
- - `Files scanned >= 6`
- - `Unique URLs >= 8`
- - `Primary-source markers >= 3`
- - `Source metadata blocks >= 6`
- - `Contradiction bullets >= 6`
- - `Inference bullets >= 6`
- - `Potential long quote lines = 0`
- - `Track coverage count = 6`
- - URLs in notes are actual inspected pages, not platform roots, search/topic pages, or placeholder paths
- If these do not hold, keep filling the weak tracks before continuing to any review stage.
-8. **Quality checkpoint (Phase 1.5)**: before entering audit, show the user a structured collection summary (with primary-source ratio, contradiction count, candidate mental models, known-answer candidates, thin dimensions, cold figure assessment). Wait for user confirmation before continuing.
-9. Then read:
- - `prompts/celebrity/budget_unfriendly/audit.md`
- - `prompts/celebrity/budget_unfriendly/synthesis.md`
- - `references/celebrity_budget_unfriendly_template.md`
-10. First write `knowledge/research/reviews/research_audit.md`
- - The audit must produce an explicit `PASS / FAIL`
- - The audit must verify: source hierarchy compliance (no blacklisted sources), primary-source ratio > 50%, taste principle compliance, cold figure assessment
- - If the audit says `FAIL`, follow the Backfill Tasks before synthesis
-11. **Extraction checkpoint (Phase 2.5)**: after audit PASS, show the user a summary of candidate mental models (with triple-gate verdict, evidence anchors, failure modes). Confirm reasonableness before synthesis.
-12. Then write `knowledge/research/reviews/synthesis.md`
- - Apply the triple gate to candidate mental models:
- - cross-context recurrence
- - generative power
- - exclusivity
- - Also extract intellectual genealogy seeds (influenced by / diverged from) and Agentic Protocol seeds (the dimensions this person would investigate when facing a novel question)
-13. Then use `prompts/celebrity/budget_unfriendly/validation.md` to write:
- - `knowledge/research/reviews/validation.md`
- - Validation must produce an explicit `PASS / FAIL`
- - Validation must perform: known-answer check (≥2 questions) + edge-case check (1 question) + voice check (100-word blind test) + copyright check + Agentic Protocol check
- - If validation says `FAIL`, revise the draft before continuing
-14. Budget-unfriendly celebrity analysis must prioritize:
- - six-track raw notes
- - merged research summary
- - research audit
- - synthesis review (with genealogy + Agentic Protocol seeds)
- - validation review
- - explicit user notes
-
-Shared rules for both celebrity profiles:
-
-- If external collection fails or a platform blocks access:
- - tell the user exactly what was blocked
- - preserve the raw research notes and merged summary
- - continue generation with the available materials
- - treat `source_grounding` as incomplete
- - **never** invent URLs, quotes, titles, or generic homepage links just to satisfy the checker
-- **Do not** store full transcripts, full subtitles, or long verbatim source passages in the repository
-- Keep the stored notes paraphrased, structured, and copyright-safe
-
-Once the family is resolved, analyze along two tracks:
-
-**Track A (Work Skill)**:
-- Refer to `prompts/work_analyzer.md`
-- Extract: responsible systems, technical standards, workflow, output preferences, experience
-- For `celebrity`, interpret `work` as methods, judgment frameworks, and decision patterns rather than literal job scope
-
-**Track B (Persona)**:
-- Use the family-specific persona analyzer
-- If `celebrity` with `research_profile=budget-unfriendly`, use:
- - `prompts/celebrity/budget_unfriendly/persona_analyzer.md`
-- Translate user-provided tags into concrete behavior rules
-- Extract from materials: communication style, decision patterns, interpersonal behavior
-- For `celebrity`, retain:
- - mental models
- - decision heuristics
- - expression DNA
- - contradictions
- - honest boundaries
-
-### Step 4: Generate and Preview
-
-Use `prompts/work_builder.md` to generate Work content.
-Use the family-specific persona builder to generate Persona content.
-
-Mapping:
-- `colleague` → `prompts/persona_builder.md`
-- `relationship` → `prompts/relationship/persona_builder.md`
-- `celebrity` → `prompts/celebrity/persona_builder.md`
-- `celebrity` + `budget-unfriendly` → `prompts/celebrity/budget_unfriendly/persona_builder.md`
-
-Show the user a summary (5-8 lines each), ask:
-```
-Work Skill Summary:
- - Responsible for: {xxx}
- - Tech stack: {xxx}
- - CR focus: {xxx}
- ...
-
-Persona Summary:
- - Core personality: {xxx}
- - Communication style: {xxx}
- - Decision pattern: {xxx}
- ...
-
-Confirm generation? Or need adjustments?
+distilly skill list
```
-### Step 5: Write Files
-
-After user confirmation, do not hand-build a `skills/colleague/{slug}`-style tree. Always go through the writer:
-
-1. Resolve the current storage root:
- - `colleague` → `./skills/colleague`
- - `relationship` → `./skills/relationship`
- - `celebrity` → `./skills/celebrity`
-2. Use the `Write` tool to create three temporary files:
- - `/tmp/distilly_{slug}_meta.json`
- - `/tmp/distilly_{slug}_work.md`
- - `/tmp/distilly_{slug}_persona.md`
-3. The temporary meta file must include at least:
- - `name`
- - `display_name`
- - `character`
- - `research_profile` (required when `character=celebrity`)
- - `classification.language` (must match the user's language, for example `zh-CN` or `en`)
- - `profile`
- - `tags`
- - `knowledge_sources`
-4. Then call:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action create \
- --character {character} \
- --research-profile {research_profile} \
- --slug {slug} \
- --name "{name}" \
- --meta /tmp/distilly_{slug}_meta.json \
- --work /tmp/distilly_{slug}_work.md \
- --persona /tmp/distilly_{slug}_persona.md \
- --base-dir {resolved_base_dir}
- ```
-5. This command will generate:
- - `SKILL.md`
- - `work.md`
- - `persona.md`
- - `work_skill.md`
- - `persona_skill.md`
- - `manifest.json`
- - `meta.json`
- - To install the generated role skill into a host, append the relevant flag:
- - Claude Code: `--install-claude-skill`
- - OpenClaw: `--install-openclaw-skill`
- - Codex: `--install-codex-skill`
- - Hermes: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host hermes --force`; for a trusted project, append `--skills-dir .hermes/skills`, run `hermes skills trust`, then start a new session or run `/reload-skills`. Use `~/.agents/skills` only when it is explicitly configured in Hermes `skills.external_dirs`
- - DeepSeek Harness: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host deepseek-harness --force`; append `--skills-dir .dsh/skills` for a project install
- - Pi: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host pi --force`; append `--skills-dir .pi/skills` for a project install, then invoke it with `/skill:{character}-{slug}`
- - Grok Build: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host grok-build --force`; append `--skills-dir .grok/skills` for a project install
- - OpenCode: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host opencode --force`; append `--skills-dir .opencode/skills` for a project install
- - The shared installer writes only the self-contained `SKILL.md` and install metadata and normalizes legacy frontmatter in the installed copy. Do not manually copy the whole generated directory; it may contain private source material
- - Claude Code on Windows: optionally add `--install-claude-command-shim`
-6. If the current family is `celebrity`, run a quality check after creation:
- ```bash
- python3 "{distilly_skill_root}/tools/research/quality_check.py" "{resolved_base_dir}/{slug}/SKILL.md" --profile {research_profile}
- ```
-7. If `source_grounding` still fails for a `celebrity` skill:
- - you may add honest limitation notes and a grounded source summary
- - only add URLs when they are real, specific, and traceable sources
- - **never** use site roots, topic pages, search pages, or other generic links as fake grounding
- - if no verified external sources exist, keep the FAIL state and explain what source material is still missing
-
-When reporting success, return the correct family-specific location instead of assuming colleague storage.
-
----
-
-## Evolution Mode: Append Files
-
-When user provides new files or text:
-
-1. Read new content using Step 2 methods
-2. Resolve the base dir for the current family
-3. `Read` existing `{resolved_base_dir}/{slug}/work.md` and `persona.md`
-4. Use the family-specific merger prompt for incremental analysis
-5. Archive current version (Bash):
- ```bash
- python3 "{distilly_skill_root}/tools/version_manager.py" \
- --action backup \
- --character {character} \
- --slug {slug} \
- --base-dir {resolved_base_dir}
- ```
-6. Write work/persona delta into temporary patch files
-7. Call:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --work-patch /tmp/distilly_{slug}_work_patch.md \
- --persona-patch /tmp/distilly_{slug}_persona_patch.md \
- --base-dir {resolved_base_dir}
- ```
-8. If the current family is `celebrity`, run the quality check again after the update
+Roll back a specific skill version:
----
+```bash
+distilly skill version
+```
-## Evolution Mode: Conversation Correction
+Delete a specific skill (after confirming the character family):
-When user expresses "that's wrong" / "he should be":
-
-1. Refer to `prompts/correction_handler.md` to identify correction content
-2. Determine if it belongs to Work (technical/workflow) or Persona (personality/communication)
-3. If it belongs to Work:
- - Generate `/tmp/distilly_{slug}_work_patch.md`
- - The patch must be one or more replaceable `##` sections
- - Call:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --work-patch /tmp/distilly_{slug}_work_patch.md \
- --base-dir {resolved_base_dir}
- ```
-4. If it belongs to Persona:
- - Write the correction record to `/tmp/distilly_{slug}_correction.json`
- - For a single correction, write `{scene, wrong, correct}`
- - For multiple persona corrections, write `{"persona_corrections": [{...}, {...}]}`
- - Call:
- ```bash
- python3 "{distilly_skill_root}/tools/skill_writer.py" \
- --action update \
- --character {character} \
- --slug {slug} \
- --correction-json /tmp/distilly_{slug}_correction.json \
- --base-dir {resolved_base_dir}
- ```
-5. If the current family is `celebrity`, run the quality check again after the update
-6. Do not hand-edit `work.md`, `persona.md`, `SKILL.md`, or `meta.json`; always update through `skill_writer.py`
+```bash
+rm -rf skills/colleague/{slug}
+rm -rf skills/relationship/{slug}
+rm -rf skills/celebrity/{slug}
+```
----
+Install into a host: `distilly install `; uninstall: `distilly uninstall`.
-## Management Operations
+List and revoke granted consent:
-List skills across the three families:
```bash
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character colleague --base-dir ./skills/colleague
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character relationship --base-dir ./skills/relationship
-python3 "{distilly_skill_root}/tools/skill_writer.py" --action list --character celebrity --base-dir ./skills/celebrity
+distilly consent list
+distilly consent revoke
```
-Roll back a specific skill version:
-```bash
-# colleague
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character colleague --slug {slug} --version {version} --base-dir ./skills/colleague
+---
-# relationship
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character relationship --slug {slug} --version {version} --base-dir ./skills/relationship
+## MUST
-# celebrity
-python3 "{distilly_skill_root}/tools/version_manager.py" --action rollback --character celebrity --slug {slug} --version {version} --base-dir ./skills/celebrity
-```
+- First list "which files were read, how many rows each, how many anchors", then write conclusions; every conclusion carries `file + anchor`.
+- With no evidence write `unknown`; candidates never become conclusions.
+- Check every step against the completion criteria of the five-step mainline before moving on.
-Delete a specific skill:
-After confirming the character family:
-```bash
-# colleague
-rm -rf skills/colleague/{slug}
+## MUST NOT
-# relationship
-rm -rf skills/relationship/{slug}
+- Never rewrite quotes; never fabricate URLs, anchors, book titles, or video titles; never pad sources with platform roots.
+- Credentials are read only from `~/.distilly/*_config.json` or environment variables and never appear in chat, files, receipts, or logs.
+- Never hand-craft platform API calls: all network collection goes through `distilly collect` / `distilly harvest` / `distilly parse-chat` / `distilly parse-email` / `distilly parse-subtitle` / `distilly parse-doc` / `distilly parse-archive` / `distilly transcribe`.
+- Never degrade silently: failures, unavailable channels, and skipped steps are all stated.
-# celebrity
-rm -rf skills/celebrity/{slug}
-```
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/assets/distilly-template.html b/assets/distilly-template.html
new file mode 100644
index 00000000..cf3d9588
--- /dev/null
+++ b/assets/distilly-template.html
@@ -0,0 +1,1088 @@
+
+
+
+
+
+
+
+
+
+Distilly · 个人画像
+
+
+
+跳到正文 / Skip to content
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/pinyin.json b/assets/pinyin.json
new file mode 100644
index 00000000..8793dbc5
--- /dev/null
+++ b/assets/pinyin.json
@@ -0,0 +1,3528 @@
+{
+ "_comment": "Derived pinyin table for slug generation. Generated file — run `node scripts/generate-pinyin.mjs` to refresh; `--check` fails on drift.",
+ "source": {
+ "database": "Unihan",
+ "url": "https://www.unicode.org/Public/UCD/latest/ucd/Unihan.zip",
+ "file": "Unihan_Readings.txt",
+ "fields": [
+ "kMandarin",
+ "kHanyuPinlu"
+ ],
+ "unicode_version": "17.0.0",
+ "source_date": "2025-07-24 00:00:00 GMT [KL]"
+ },
+ "license": {
+ "name": "Unicode License v3",
+ "url": "https://www.unicode.org/license.txt",
+ "notice": "Unihan data is Copyright © Unicode, Inc. and distributed under the Unicode License v3; see the URL above for the full text."
+ },
+ "generated_by": "scripts/generate-pinyin.mjs",
+ "selection": {
+ "rule": "top N Han characters by summed kHanyuPinlu frequency; reading = first kMandarin, else the most frequent kHanyuPinlu reading",
+ "limit": 3500,
+ "covered_characters": 44348
+ },
+ "count": 3500,
+ "characters": {
+ "的": "de",
+ "一": "yī",
+ "了": "le",
+ "是": "shì",
+ "不": "bù",
+ "我": "wǒ",
+ "在": "zài",
+ "有": "yǒu",
+ "人": "rén",
+ "这": "zhè",
+ "這": "zhè",
+ "他": "tā",
+ "们": "men",
+ "們": "men",
+ "來": "lái",
+ "来": "lái",
+ "个": "gè",
+ "個": "gè",
+ "上": "shàng",
+ "地": "de",
+ "大": "dà",
+ "著": "zhù",
+ "着": "zhe",
+ "就": "jiù",
+ "你": "nǐ",
+ "到": "dào",
+ "說": "shuō",
+ "说": "shuō",
+ "和": "hé",
+ "要": "yào",
+ "里": "lǐ",
+ "么": "me",
+ "子": "zǐ",
+ "也": "yě",
+ "去": "qù",
+ "那": "nà",
+ "得": "dé",
+ "主": "zhǔ",
+ "会": "huì",
+ "會": "huì",
+ "时": "shí",
+ "時": "shí",
+ "出": "chū",
+ "下": "xià",
+ "国": "guó",
+ "國": "guó",
+ "过": "guò",
+ "過": "guò",
+ "为": "wèi",
+ "為": "wèi",
+ "好": "hǎo",
+ "以": "yǐ",
+ "看": "kàn",
+ "可": "kě",
+ "还": "hái",
+ "還": "hái",
+ "生": "shēng",
+ "都": "dōu",
+ "学": "xué",
+ "學": "xué",
+ "沒": "méi",
+ "没": "méi",
+ "起": "qǐ",
+ "能": "néng",
+ "多": "duō",
+ "年": "nián",
+ "小": "xiǎo",
+ "把": "bǎ",
+ "天": "tiān",
+ "工": "gōng",
+ "家": "jiā",
+ "发": "fā",
+ "發": "fā",
+ "动": "dòng",
+ "動": "dòng",
+ "对": "duì",
+ "對": "duì",
+ "用": "yòng",
+ "中": "zhōng",
+ "又": "yòu",
+ "作": "zuò",
+ "同": "tóng",
+ "民": "mín",
+ "自": "zì",
+ "样": "yàng",
+ "樣": "yàng",
+ "想": "xiǎng",
+ "面": "miàn",
+ "成": "chéng",
+ "她": "tā",
+ "义": "yì",
+ "義": "yì",
+ "后": "hòu",
+ "經": "jīng",
+ "经": "jīng",
+ "产": "chǎn",
+ "產": "chǎn",
+ "十": "shí",
+ "什": "shén",
+ "道": "dào",
+ "进": "jìn",
+ "進": "jìn",
+ "现": "xiàn",
+ "現": "xiàn",
+ "只": "zhǐ",
+ "儿": "ér",
+ "兒": "ér",
+ "点": "diǎn",
+ "點": "diǎn",
+ "头": "tóu",
+ "頭": "tóu",
+ "种": "zhǒng",
+ "種": "zhǒng",
+ "从": "cóng",
+ "從": "cóng",
+ "甚": "shèn",
+ "些": "xiē",
+ "很": "hěn",
+ "然": "rán",
+ "前": "qián",
+ "見": "jiàn",
+ "见": "jiàn",
+ "老": "lǎo",
+ "事": "shì",
+ "方": "fāng",
+ "分": "fēn",
+ "于": "yú",
+ "开": "kāi",
+ "而": "ér",
+ "開": "kāi",
+ "麼": "me",
+ "心": "xīn",
+ "两": "liǎng",
+ "兩": "liǎng",
+ "走": "zǒu",
+ "行": "xíng",
+ "長": "zhǎng",
+ "长": "zhǎng",
+ "高": "gāo",
+ "象": "xiàng",
+ "三": "sān",
+ "当": "dāng",
+ "當": "dāng",
+ "它": "tā",
+ "氣": "qì",
+ "回": "huí",
+ "給": "gěi",
+ "给": "gěi",
+ "实": "shí",
+ "實": "shí",
+ "問": "wèn",
+ "问": "wèn",
+ "全": "quán",
+ "水": "shuǐ",
+ "部": "bù",
+ "几": "jǐ",
+ "二": "èr",
+ "命": "mìng",
+ "正": "zhèng",
+ "定": "dìng",
+ "党": "dǎng",
+ "黨": "dǎng",
+ "手": "shǒu",
+ "力": "lì",
+ "己": "jǐ",
+ "机": "jī",
+ "機": "jī",
+ "气": "qì",
+ "意": "yì",
+ "向": "xiàng",
+ "所": "suǒ",
+ "幾": "jǐ",
+ "知": "zhī",
+ "等": "děng",
+ "社": "shè",
+ "物": "wù",
+ "理": "lǐ",
+ "战": "zhàn",
+ "戰": "zhàn",
+ "边": "biān",
+ "邊": "biān",
+ "話": "huà",
+ "话": "huà",
+ "候": "hòu",
+ "但": "dàn",
+ "呢": "ne",
+ "声": "shēng",
+ "本": "běn",
+ "聲": "shēng",
+ "如": "rú",
+ "吶": "nà",
+ "呐": "nà",
+ "使": "shǐ",
+ "之": "zhī",
+ "打": "dǎ",
+ "叫": "jiào",
+ "外": "wài",
+ "罢": "bà",
+ "罷": "bà",
+ "法": "fǎ",
+ "眼": "yǎn",
+ "情": "qíng",
+ "做": "zuò",
+ "身": "shēn",
+ "重": "zhòng",
+ "化": "huà",
+ "革": "gé",
+ "才": "cái",
+ "間": "jiān",
+ "间": "jiān",
+ "反": "fǎn",
+ "已": "yǐ",
+ "四": "sì",
+ "最": "zuì",
+ "真": "zhēn",
+ "业": "yè",
+ "業": "yè",
+ "怎": "zěn",
+ "志": "zhì",
+ "听": "tīng",
+ "聽": "tīng",
+ "吧": "ba",
+ "別": "bié",
+ "别": "bié",
+ "級": "jí",
+ "级": "jí",
+ "放": "fàng",
+ "妈": "mā",
+ "媽": "mā",
+ "无": "wú",
+ "無": "wú",
+ "路": "lù",
+ "明": "míng",
+ "先": "xiān",
+ "干": "gàn",
+ "因": "yīn",
+ "新": "xīn",
+ "量": "liàng",
+ "車": "chē",
+ "车": "chē",
+ "文": "wén",
+ "阶": "jiē",
+ "階": "jiē",
+ "代": "dài",
+ "少": "shǎo",
+ "五": "wǔ",
+ "加": "jiā",
+ "解": "jiě",
+ "制": "zhì",
+ "政": "zhèng",
+ "军": "jūn",
+ "軍": "jūn",
+ "度": "dù",
+ "活": "huó",
+ "各": "gè",
+ "住": "zhù",
+ "电": "diàn",
+ "電": "diàn",
+ "比": "bǐ",
+ "员": "yuán",
+ "員": "yuán",
+ "第": "dì",
+ "常": "cháng",
+ "关": "guān",
+ "關": "guān",
+ "体": "tǐ",
+ "體": "tǐ",
+ "建": "jiàn",
+ "口": "kǒu",
+ "太": "tài",
+ "次": "cì",
+ "争": "zhēng",
+ "爭": "zhēng",
+ "月": "yuè",
+ "山": "shān",
+ "原": "yuán",
+ "再": "zài",
+ "吃": "chī",
+ "变": "biàn",
+ "變": "biàn",
+ "应": "yīng",
+ "應": "yīng",
+ "果": "guǒ",
+ "門": "mén",
+ "门": "mén",
+ "題": "tí",
+ "题": "tí",
+ "条": "tiáo",
+ "條": "tiáo",
+ "西": "xī",
+ "光": "guāng",
+ "思": "sī",
+ "由": "yóu",
+ "快": "kuài",
+ "利": "lì",
+ "表": "biǎo",
+ "东": "dōng",
+ "東": "dōng",
+ "总": "zǒng",
+ "總": "zǒng",
+ "您": "nín",
+ "合": "hé",
+ "立": "lì",
+ "百": "bǎi",
+ "提": "tí",
+ "吗": "ma",
+ "嗎": "ma",
+ "被": "bèi",
+ "跟": "gēn",
+ "領": "lǐng",
+ "领": "lǐng",
+ "結": "jié",
+ "结": "jié",
+ "啊": "a",
+ "决": "jué",
+ "決": "jué",
+ "完": "wán",
+ "平": "píng",
+ "教": "jiào",
+ "队": "duì",
+ "隊": "duì",
+ "論": "lùn",
+ "论": "lùn",
+ "許": "xǔ",
+ "许": "xǔ",
+ "科": "kē",
+ "其": "qí",
+ "亲": "qīn",
+ "親": "qīn",
+ "資": "zī",
+ "资": "zī",
+ "者": "zhě",
+ "九": "jiǔ",
+ "展": "zhǎn",
+ "书": "shū",
+ "書": "shū",
+ "內": "nèi",
+ "内": "nèi",
+ "更": "gèng",
+ "并": "bìng",
+ "呀": "ya",
+ "哪": "nǎ",
+ "导": "dǎo",
+ "導": "dǎo",
+ "笑": "xiào",
+ "性": "xìng",
+ "白": "bái",
+ "系": "xì",
+ "造": "zào",
+ "斗": "dòu",
+ "相": "xiāng",
+ "带": "dài",
+ "帶": "dài",
+ "万": "wàn",
+ "萬": "wàn",
+ "敌": "dí",
+ "敵": "dí",
+ "指": "zhǐ",
+ "界": "jiè",
+ "共": "gòng",
+ "接": "jiē",
+ "直": "zhí",
+ "便": "biàn",
+ "公": "gōng",
+ "往": "wǎng",
+ "农": "nóng",
+ "農": "nóng",
+ "線": "xiàn",
+ "线": "xiàn",
+ "記": "jì",
+ "记": "jì",
+ "日": "rì",
+ "位": "wèi",
+ "認": "rèn",
+ "认": "rèn",
+ "每": "měi",
+ "研": "yán",
+ "今": "jīn",
+ "世": "shì",
+ "将": "jiāng",
+ "將": "jiāng",
+ "任": "rèn",
+ "孩": "hái",
+ "根": "gēn",
+ "花": "huā",
+ "难": "nán",
+ "難": "nán",
+ "区": "qū",
+ "區": "qū",
+ "覺": "jué",
+ "觉": "jué",
+ "群": "qún",
+ "运": "yùn",
+ "運": "yùn",
+ "办": "bàn",
+ "辦": "bàn",
+ "風": "fēng",
+ "风": "fēng",
+ "数": "shù",
+ "數": "shù",
+ "望": "wàng",
+ "究": "jiū",
+ "識": "shí",
+ "识": "shí",
+ "写": "xiě",
+ "寫": "xiě",
+ "处": "chù",
+ "處": "chù",
+ "女": "nǚ",
+ "治": "zhì",
+ "件": "jiàn",
+ "流": "liú",
+ "却": "què",
+ "卻": "què",
+ "众": "zhòng",
+ "眾": "zhòng",
+ "半": "bàn",
+ "师": "shī",
+ "師": "shī",
+ "通": "tōng",
+ "愛": "ài",
+ "爱": "ài",
+ "或": "huò",
+ "拿": "ná",
+ "八": "bā",
+ "形": "xíng",
+ "步": "bù",
+ "此": "cǐ",
+ "計": "jì",
+ "计": "jì",
+ "必": "bì",
+ "站": "zhàn",
+ "特": "tè",
+ "設": "shè",
+ "设": "shè",
+ "改": "gǎi",
+ "受": "shòu",
+ "连": "lián",
+ "連": "lián",
+ "信": "xìn",
+ "切": "qiè",
+ "誰": "shuí",
+ "谁": "shéi",
+ "強": "qiáng",
+ "强": "qiáng",
+ "該": "gāi",
+ "该": "gāi",
+ "朮": "shù",
+ "术": "shù",
+ "且": "qiě",
+ "找": "zhǎo",
+ "算": "suàn",
+ "远": "yuǎn",
+ "遠": "yuǎn",
+ "六": "liù",
+ "满": "mǎn",
+ "滿": "mǎn",
+ "觀": "guān",
+ "观": "guān",
+ "早": "zǎo",
+ "報": "bào",
+ "报": "bào",
+ "坐": "zuò",
+ "热": "rè",
+ "熱": "rè",
+ "期": "qī",
+ "济": "jì",
+ "濟": "jì",
+ "石": "shí",
+ "似": "sì",
+ "告": "gào",
+ "够": "gòu",
+ "夠": "gòu",
+ "跑": "pǎo",
+ "啦": "la",
+ "管": "guǎn",
+ "料": "liào",
+ "感": "gǎn",
+ "爸": "bà",
+ "講": "jiǎng",
+ "讓": "ràng",
+ "让": "ràng",
+ "讲": "jiǎng",
+ "与": "yǔ",
+ "與": "yǔ",
+ "組": "zǔ",
+ "组": "zǔ",
+ "統": "tǒng",
+ "统": "tǒng",
+ "河": "hé",
+ "越": "yuè",
+ "火": "huǒ",
+ "爷": "yé",
+ "爺": "yé",
+ "服": "fú",
+ "七": "qī",
+ "色": "sè",
+ "飛": "fēi",
+ "飞": "fēi",
+ "轉": "zhuǎn",
+ "转": "zhuǎn",
+ "死": "sǐ",
+ "脸": "liǎn",
+ "臉": "liǎn",
+ "块": "kuài",
+ "塊": "kuài",
+ "确": "què",
+ "確": "què",
+ "空": "kōng",
+ "船": "chuán",
+ "务": "wù",
+ "務": "wù",
+ "取": "qǔ",
+ "场": "chǎng",
+ "場": "chǎng",
+ "海": "hǎi",
+ "极": "jí",
+ "極": "jí",
+ "質": "zhì",
+ "质": "zhì",
+ "准": "zhǔn",
+ "紧": "jǐn",
+ "緊": "jǐn",
+ "整": "zhěng",
+ "倒": "dào",
+ "基": "jī",
+ "錢": "qián",
+ "钱": "qián",
+ "馬": "mǎ",
+ "马": "mǎ",
+ "团": "tuán",
+ "團": "tuán",
+ "照": "zhào",
+ "千": "qiān",
+ "品": "pǐn",
+ "神": "shén",
+ "刚": "gāng",
+ "剛": "gāng",
+ "怕": "pà",
+ "輕": "qīng",
+ "轻": "qīng",
+ "土": "tǔ",
+ "劳": "láo",
+ "勞": "láo",
+ "树": "shù",
+ "樹": "shù",
+ "影": "yǐng",
+ "保": "bǎo",
+ "史": "shǐ",
+ "細": "xì",
+ "细": "xì",
+ "紅": "hóng",
+ "红": "hóng",
+ "习": "xí",
+ "習": "xí",
+ "程": "chéng",
+ "青": "qīng",
+ "近": "jìn",
+ "容": "róng",
+ "油": "yóu",
+ "历": "lì",
+ "歷": "lì",
+ "清": "qīng",
+ "求": "qiú",
+ "送": "sòng",
+ "錯": "cuò",
+ "错": "cuò",
+ "字": "zì",
+ "目": "mù",
+ "村": "cūn",
+ "裡": "lǐ",
+ "据": "jù",
+ "據": "jù",
+ "席": "xí",
+ "片": "piàn",
+ "夜": "yè",
+ "較": "jiào",
+ "较": "jiào",
+ "响": "xiǎng",
+ "響": "xiǎng",
+ "类": "lèi",
+ "類": "lèi",
+ "驗": "yàn",
+ "验": "yàn",
+ "离": "lí",
+ "離": "lí",
+ "底": "dǐ",
+ "至": "zhì",
+ "张": "zhāng",
+ "張": "zhāng",
+ "備": "bèi",
+ "入": "rù",
+ "备": "bèi",
+ "米": "mǐ",
+ "买": "mǎi",
+ "屋": "wū",
+ "買": "mǎi",
+ "深": "shēn",
+ "器": "qì",
+ "收": "shōu",
+ "名": "míng",
+ "咱": "zán",
+ "規": "guī",
+ "规": "guī",
+ "集": "jí",
+ "需": "xū",
+ "南": "nán",
+ "勝": "shèng",
+ "胜": "shèng",
+ "布": "bù",
+ "病": "bìng",
+ "具": "jù",
+ "鐵": "tiě",
+ "铁": "tiě",
+ "須": "xū",
+ "须": "xū",
+ "織": "zhī",
+ "织": "zhī",
+ "装": "zhuāng",
+ "裝": "zhuāng",
+ "厂": "chǎng",
+ "廠": "chǎng",
+ "晚": "wǎn",
+ "北": "běi",
+ "睛": "jīng",
+ "及": "jí",
+ "况": "kuàng",
+ "況": "kuàng",
+ "院": "yuàn",
+ "传": "chuán",
+ "傳": "chuán",
+ "友": "yǒu",
+ "技": "jì",
+ "哥": "gē",
+ "房": "fáng",
+ "消": "xiāo",
+ "包": "bāo",
+ "际": "jì",
+ "際": "jì",
+ "母": "mǔ",
+ "坚": "jiān",
+ "堅": "jiān",
+ "批": "pī",
+ "談": "tán",
+ "谈": "tán",
+ "何": "hé",
+ "市": "shì",
+ "黑": "hēi",
+ "非": "fēi",
+ "忙": "máng",
+ "断": "duàn",
+ "斷": "duàn",
+ "赶": "gǎn",
+ "趕": "gǎn",
+ "汽": "qì",
+ "族": "zú",
+ "睡": "shuì",
+ "拉": "lā",
+ "委": "wěi",
+ "速": "sù",
+ "低": "dī",
+ "精": "jīng",
+ "兴": "xìng",
+ "抗": "kàng",
+ "興": "xìng",
+ "害": "hài",
+ "围": "wéi",
+ "圍": "wéi",
+ "刻": "kè",
+ "派": "pài",
+ "答": "dá",
+ "衣": "yī",
+ "苦": "kǔ",
+ "击": "jī",
+ "擊": "jī",
+ "交": "jiāo",
+ "娘": "niáng",
+ "支": "zhī",
+ "音": "yīn",
+ "严": "yán",
+ "嚴": "yán",
+ "广": "guǎng",
+ "廣": "guǎng",
+ "脚": "jiǎo",
+ "腳": "jiǎo",
+ "压": "yā",
+ "壓": "yā",
+ "句": "jù",
+ "急": "jí",
+ "坏": "huài",
+ "壞": "huài",
+ "草": "cǎo",
+ "嘴": "zuǐ",
+ "艺": "yì",
+ "藝": "yì",
+ "始": "shǐ",
+ "帝": "dì",
+ "破": "pò",
+ "单": "dān",
+ "單": "dān",
+ "調": "diào",
+ "调": "diào",
+ "专": "zhuān",
+ "專": "zhuān",
+ "增": "zēng",
+ "持": "chí",
+ "随": "suí",
+ "隨": "suí",
+ "帮": "bāng",
+ "幫": "bāng",
+ "安": "ān",
+ "訴": "sù",
+ "诉": "sù",
+ "穿": "chuān",
+ "城": "chéng",
+ "乎": "hū",
+ "士": "shì",
+ "請": "qǐng",
+ "请": "qǐng",
+ "联": "lián",
+ "聯": "lián",
+ "式": "shì",
+ "阵": "zhèn",
+ "陣": "zhèn",
+ "伟": "wěi",
+ "偉": "wěi",
+ "議": "yì",
+ "议": "yì",
+ "客": "kè",
+ "金": "jīn",
+ "星": "xīng",
+ "般": "bān",
+ "积": "jī",
+ "積": "jī",
+ "商": "shāng",
+ "复": "fù",
+ "複": "fù",
+ "达": "dá",
+ "達": "dá",
+ "飯": "fàn",
+ "饭": "fàn",
+ "約": "yuē",
+ "约": "yuē",
+ "虫": "chóng",
+ "参": "cān",
+ "參": "cān",
+ "举": "jǔ",
+ "亮": "liàng",
+ "舉": "jǔ",
+ "桥": "qiáo",
+ "橋": "qiáo",
+ "育": "yù",
+ "左": "zuǒ",
+ "雨": "yǔ",
+ "虽": "suī",
+ "雖": "suī",
+ "魚": "yú",
+ "鱼": "yú",
+ "兵": "bīng",
+ "毛": "máo",
+ "则": "zé",
+ "則": "zé",
+ "忽": "hū",
+ "節": "jié",
+ "节": "jié",
+ "推": "tuī",
+ "段": "duàn",
+ "卖": "mài",
+ "台": "tái",
+ "易": "yì",
+ "賣": "mài",
+ "落": "luò",
+ "鋼": "gāng",
+ "钢": "gāng",
+ "失": "shī",
+ "愿": "yuàn",
+ "材": "cái",
+ "靠": "kào",
+ "伙": "huǒ",
+ "岁": "suì",
+ "歲": "suì",
+ "皮": "pí",
+ "証": "zhèng",
+ "证": "zhèng",
+ "父": "fù",
+ "朋": "péng",
+ "阳": "yáng",
+ "陽": "yáng",
+ "即": "jí",
+ "微": "wēi",
+ "誤": "wù",
+ "误": "wù",
+ "停": "tíng",
+ "示": "shì",
+ "划": "huà",
+ "局": "jú",
+ "背": "bèi",
+ "显": "xiǎn",
+ "顯": "xiǎn",
+ "欢": "huān",
+ "歡": "huān",
+ "夫": "fū",
+ "引": "yǐn",
+ "息": "xī",
+ "除": "chú",
+ "温": "wēn",
+ "溫": "wēn",
+ "画": "huà",
+ "畫": "huà",
+ "食": "shí",
+ "首": "shǒu",
+ "图": "tú",
+ "圖": "tú",
+ "右": "yòu",
+ "号": "hào",
+ "號": "hào",
+ "續": "xù",
+ "续": "xù",
+ "层": "céng",
+ "層": "céng",
+ "呼": "hū",
+ "留": "liú",
+ "敢": "gǎn",
+ "权": "quán",
+ "權": "quán",
+ "灯": "dēng",
+ "燈": "dēng",
+ "密": "mì",
+ "旧": "jiù",
+ "舊": "jiù",
+ "静": "jìng",
+ "靜": "jìng",
+ "另": "lìng",
+ "突": "tū",
+ "掉": "diào",
+ "旁": "páng",
+ "查": "chá",
+ "跳": "tiào",
+ "护": "hù",
+ "護": "hù",
+ "久": "jiǔ",
+ "紀": "jì",
+ "纪": "jì",
+ "紙": "zhǐ",
+ "纸": "zhǐ",
+ "美": "měi",
+ "雪": "xuě",
+ "修": "xiū",
+ "助": "zhù",
+ "喊": "hǎn",
+ "冲": "chōng",
+ "沖": "chōng",
+ "医": "yī",
+ "存": "cún",
+ "醫": "yī",
+ "喜": "xǐ",
+ "渐": "jiàn",
+ "漸": "jiàn",
+ "球": "qiú",
+ "姑": "gū",
+ "呵": "hē",
+ "激": "jī",
+ "令": "lìng",
+ "冷": "lěng",
+ "势": "shì",
+ "勢": "shì",
+ "创": "chuàng",
+ "創": "chuàng",
+ "弟": "dì",
+ "念": "niàn",
+ "沉": "chén",
+ "注": "zhù",
+ "略": "lüè",
+ "頂": "dǐng",
+ "顶": "dǐng",
+ "古": "gǔ",
+ "律": "lǜ",
+ "按": "àn",
+ "評": "píng",
+ "评": "píng",
+ "脑": "nǎo",
+ "腦": "nǎo",
+ "室": "shì",
+ "搞": "gǎo",
+ "乡": "xiāng",
+ "唱": "chàng",
+ "鄉": "xiāng",
+ "府": "fǔ",
+ "讀": "dú",
+ "读": "dú",
+ "简": "jiǎn",
+ "簡": "jiǎn",
+ "价": "jià",
+ "價": "jià",
+ "养": "yǎng",
+ "板": "bǎn",
+ "養": "yǎng",
+ "县": "xiàn",
+ "縣": "xiàn",
+ "校": "xiào",
+ "烈": "liè",
+ "惊": "jīng",
+ "沙": "shā",
+ "驚": "jīng",
+ "章": "zhāng",
+ "視": "shì",
+ "视": "shì",
+ "采": "cǎi",
+ "維": "wéi",
+ "维": "wéi",
+ "血": "xuè",
+ "姐": "jiě",
+ "慢": "màn",
+ "故": "gù",
+ "木": "mù",
+ "怪": "guài",
+ "鐘": "zhōng",
+ "钟": "zhōng",
+ "省": "shěng",
+ "药": "yào",
+ "藥": "yào",
+ "角": "jiǎo",
+ "初": "chū",
+ "繼": "jì",
+ "继": "jì",
+ "抓": "zhuā",
+ "班": "bān",
+ "仅": "jǐn",
+ "僅": "jǐn",
+ "排": "pái",
+ "奶": "nǎi",
+ "封": "fēng",
+ "础": "chǔ",
+ "礎": "chǔ",
+ "烧": "shāo",
+ "燒": "shāo",
+ "周": "zhōu",
+ "喝": "hē",
+ "座": "zuò",
+ "担": "dān",
+ "擔": "dān",
+ "伤": "shāng",
+ "傷": "shāng",
+ "央": "yāng",
+ "棉": "mián",
+ "竟": "jìng",
+ "搖": "yáo",
+ "摇": "yáo",
+ "曾": "céng",
+ "困": "kùn",
+ "枪": "qiāng",
+ "槍": "qiāng",
+ "熟": "shú",
+ "終": "zhōng",
+ "终": "zhōng",
+ "功": "gōng",
+ "态": "tài",
+ "態": "tài",
+ "止": "zhǐ",
+ "源": "yuán",
+ "床": "chuáng",
+ "仍": "réng",
+ "尽": "jǐn",
+ "盡": "jǐn",
+ "懂": "dǒng",
+ "弹": "dàn",
+ "彈": "dàn",
+ "充": "chōng",
+ "防": "fáng",
+ "試": "shì",
+ "试": "shì",
+ "双": "shuāng",
+ "哭": "kū",
+ "雙": "shuāng",
+ "窗": "chuāng",
+ "吸": "xī",
+ "例": "lì",
+ "属": "shǔ",
+ "屬": "shǔ",
+ "翻": "fān",
+ "叔": "shū",
+ "祖": "zǔ",
+ "挥": "huī",
+ "揮": "huī",
+ "游": "yóu",
+ "缺": "quē",
+ "責": "zé",
+ "责": "zé",
+ "模": "mó",
+ "野": "yě",
+ "乱": "luàn",
+ "亂": "luàn",
+ "杂": "zá",
+ "痛": "tòng",
+ "适": "shì",
+ "適": "shì",
+ "雜": "zá",
+ "歌": "gē",
+ "菜": "cài",
+ "替": "tì",
+ "换": "huàn",
+ "換": "huàn",
+ "妇": "fù",
+ "婦": "fù",
+ "烟": "yān",
+ "煙": "yān",
+ "負": "fù",
+ "负": "fù",
+ "黃": "huáng",
+ "黄": "huáng",
+ "奇": "qí",
+ "瞭": "liào",
+ "占": "zhàn",
+ "岸": "àn",
+ "标": "biāo",
+ "標": "biāo",
+ "待": "dài",
+ "依": "yī",
+ "侵": "qīn",
+ "值": "zhí",
+ "林": "lín",
+ "課": "kè",
+ "课": "kè",
+ "卫": "wèi",
+ "衛": "wèi",
+ "嘛": "ma",
+ "选": "xuǎn",
+ "選": "xuǎn",
+ "称": "chēng",
+ "稱": "chēng",
+ "乐": "lè",
+ "庄": "zhuāng",
+ "握": "wò",
+ "检": "jiǎn",
+ "樂": "lè",
+ "檢": "jiǎn",
+ "武": "wǔ",
+ "莊": "zhuāng",
+ "田": "tián",
+ "益": "yì",
+ "街": "jiē",
+ "嫂": "sǎo",
+ "考": "kǎo",
+ "巨": "jù",
+ "演": "yǎn",
+ "營": "yíng",
+ "营": "yíng",
+ "爬": "pá",
+ "暗": "àn",
+ "未": "wèi",
+ "滅": "miè",
+ "灭": "miè",
+ "貨": "huò",
+ "货": "huò",
+ "差": "chà",
+ "春": "chūn",
+ "固": "gù",
+ "元": "yuán",
+ "顧": "gù",
+ "顾": "gù",
+ "普": "pǔ",
+ "希": "xī",
+ "含": "hán",
+ "弄": "nòng",
+ "針": "zhēn",
+ "针": "zhēn",
+ "短": "duǎn",
+ "降": "jiàng",
+ "型": "xíng",
+ "斤": "jīn",
+ "构": "gòu",
+ "架": "jià",
+ "格": "gé",
+ "構": "gòu",
+ "供": "gōng",
+ "透": "tòu",
+ "射": "shè",
+ "富": "fù",
+ "致": "zhì",
+ "副": "fù",
+ "攻": "gōng",
+ "忘": "wàng",
+ "践": "jiàn",
+ "踐": "jiàn",
+ "足": "zú",
+ "司": "sī",
+ "危": "wēi",
+ "既": "jì",
+ "泥": "ní",
+ "笔": "bǐ",
+ "筆": "bǐ",
+ "伸": "shēn",
+ "言": "yán",
+ "朝": "cháo",
+ "迫": "pò",
+ "抬": "tái",
+ "費": "fèi",
+ "费": "fèi",
+ "景": "jǐng",
+ "永": "yǒng",
+ "哎": "āi",
+ "叶": "yè",
+ "葉": "yè",
+ "减": "jiǎn",
+ "印": "yìn",
+ "店": "diàn",
+ "減": "jiǎn",
+ "江": "jiāng",
+ "宣": "xuān",
+ "洋": "yáng",
+ "劲": "jìn",
+ "勁": "jìn",
+ "某": "mǒu",
+ "絕": "jué",
+ "绝": "jué",
+ "抱": "bào",
+ "掌": "zhǎng",
+ "环": "huán",
+ "環": "huán",
+ "配": "pèi",
+ "遍": "biàn",
+ "映": "yìng",
+ "素": "sù",
+ "謝": "xiè",
+ "谢": "xiè",
+ "互": "hù",
+ "嗯": "ǹg",
+ "察": "chá",
+ "洗": "xǐ",
+ "优": "yōu",
+ "余": "yú",
+ "優": "yōu",
+ "概": "gài",
+ "桌": "zhuō",
+ "鼓": "gǔ",
+ "鏡": "jìng",
+ "镜": "jìng",
+ "刀": "dāo",
+ "摸": "mō",
+ "效": "xiào",
+ "味": "wèi",
+ "奋": "fèn",
+ "奮": "fèn",
+ "怀": "huái",
+ "懷": "huái",
+ "唯": "wéi",
+ "境": "jìng",
+ "粮": "liáng",
+ "糧": "liáng",
+ "肯": "kěn",
+ "楚": "chǔ",
+ "盾": "dùn",
+ "矛": "máo",
+ "王": "wáng",
+ "肉": "ròu",
+ "討": "tǎo",
+ "讨": "tǎo",
+ "官": "guān",
+ "摆": "bǎi",
+ "擺": "bǎi",
+ "杀": "shā",
+ "殺": "shā",
+ "逐": "zhú",
+ "筑": "zhù",
+ "仿": "fǎng",
+ "燃": "rán",
+ "冬": "dōng",
+ "袋": "dài",
+ "追": "zhuī",
+ "列": "liè",
+ "午": "wǔ",
+ "宝": "bǎo",
+ "寶": "bǎo",
+ "挂": "guà",
+ "掛": "guà",
+ "牛": "niú",
+ "置": "zhì",
+ "状": "zhuàng",
+ "狀": "zhuàng",
+ "鞋": "xié",
+ "假": "jiǎ",
+ "順": "shùn",
+ "顺": "shùn",
+ "丰": "fēng",
+ "墙": "qiáng",
+ "投": "tóu",
+ "牆": "qiáng",
+ "独": "dú",
+ "獨": "dú",
+ "矿": "kuàng",
+ "礦": "kuàng",
+ "腿": "tuǐ",
+ "酒": "jiǔ",
+ "語": "yǔ",
+ "语": "yǔ",
+ "遇": "yù",
+ "哦": "ó",
+ "浪": "làng",
+ "端": "duān",
+ "策": "cè",
+ "园": "yuán",
+ "園": "yuán",
+ "妹": "mèi",
+ "猛": "měng",
+ "幸": "xìng",
+ "彻": "chè",
+ "徹": "chè",
+ "炼": "liàn",
+ "煉": "liàn",
+ "碎": "suì",
+ "超": "chāo",
+ "案": "àn",
+ "退": "tuì",
+ "闹": "nào",
+ "鬧": "nào",
+ "佛": "fú",
+ "判": "pàn",
+ "英": "yīng",
+ "努": "nǔ",
+ "閃": "shǎn",
+ "闪": "shǎn",
+ "煤": "méi",
+ "犯": "fàn",
+ "瞧": "qiáo",
+ "散": "sàn",
+ "男": "nán",
+ "湖": "hú",
+ "鮮": "xiān",
+ "鲜": "xiān",
+ "骨": "gǔ",
+ "枝": "zhī",
+ "練": "liàn",
+ "练": "liàn",
+ "企": "qǐ",
+ "抽": "chōu",
+ "雞": "jī",
+ "鸡": "jī",
+ "銀": "yín",
+ "银": "yín",
+ "朵": "duǒ",
+ "露": "lù",
+ "館": "guǎn",
+ "馆": "guǎn",
+ "限": "xiàn",
+ "吹": "chuī",
+ "挺": "tǐng",
+ "脫": "tuō",
+ "脱": "tuō",
+ "婶": "shěn",
+ "嬸": "shěn",
+ "季": "jì",
+ "洞": "dòng",
+ "盖": "gài",
+ "碗": "wǎn",
+ "蓋": "gài",
+ "項": "xiàng",
+ "项": "xiàng",
+ "召": "zhào",
+ "像": "xiàng",
+ "堆": "duī",
+ "泪": "lèi",
+ "淚": "lèi",
+ "姓": "xìng",
+ "折": "zhé",
+ "束": "shù",
+ "沿": "yán",
+ "率": "lǜ",
+ "輸": "shū",
+ "输": "shū",
+ "否": "fǒu",
+ "哈": "hā",
+ "削": "xuē",
+ "套": "tào",
+ "汉": "hàn",
+ "测": "cè",
+ "測": "cè",
+ "漢": "hàn",
+ "哩": "lī",
+ "毫": "háo",
+ "鬼": "guǐ",
+ "勇": "yǒng",
+ "拍": "pāi",
+ "玩": "wán",
+ "輪": "lún",
+ "轮": "lún",
+ "险": "xiǎn",
+ "險": "xiǎn",
+ "巴": "bā",
+ "硬": "yìng",
+ "移": "yí",
+ "耐": "nài",
+ "震": "zhèn",
+ "預": "yù",
+ "预": "yù",
+ "临": "lín",
+ "綠": "lǜ",
+ "绿": "lǜ",
+ "股": "gǔ",
+ "臨": "lín",
+ "倍": "bèi",
+ "碰": "pèng",
+ "執": "zhí",
+ "守": "shǒu",
+ "悄": "qiāo",
+ "执": "zhí",
+ "敗": "bài",
+ "染": "rǎn",
+ "植": "zhí",
+ "败": "bài",
+ "鋪": "pù",
+ "铺": "pù",
+ "哲": "zhé",
+ "戶": "hù",
+ "户": "hù",
+ "伍": "wǔ",
+ "救": "jiù",
+ "狗": "gǒu",
+ "羊": "yáng",
+ "鎮": "zhèn",
+ "镇": "zhèn",
+ "丽": "lì",
+ "旗": "qí",
+ "編": "biān",
+ "编": "biān",
+ "胡": "hú",
+ "麗": "lì",
+ "穷": "qióng",
+ "窮": "qióng",
+ "雄": "xióng",
+ "玻": "bō",
+ "璃": "lí",
+ "剝": "bō",
+ "剥": "bō",
+ "粉": "fěn",
+ "艰": "jiān",
+ "艱": "jiān",
+ "零": "líng",
+ "肩": "jiān",
+ "云": "yún",
+ "挑": "tiāo",
+ "混": "hùn",
+ "顆": "kē",
+ "颗": "kē",
+ "善": "shàn",
+ "戏": "xì",
+ "戲": "xì",
+ "鑽": "zuān",
+ "钻": "zuān",
+ "借": "jiè",
+ "偷": "tōu",
+ "均": "jūn",
+ "昨": "zuó",
+ "舞": "wǔ",
+ "頓": "dùn",
+ "顿": "dùn",
+ "施": "shī",
+ "洲": "zhōu",
+ "篇": "piān",
+ "厚": "hòu",
+ "陆": "lù",
+ "陸": "lù",
+ "傅": "fù",
+ "招": "zhāo",
+ "范": "fàn",
+ "醒": "xǐng",
+ "剩": "shèng",
+ "福": "fú",
+ "默": "mò",
+ "良": "liáng",
+ "警": "jǐng",
+ "躺": "tǎng",
+ "休": "xiū",
+ "升": "shēng",
+ "圆": "yuán",
+ "圓": "yuán",
+ "夏": "xià",
+ "夺": "duó",
+ "奪": "duó",
+ "恶": "è",
+ "惡": "è",
+ "纖": "xiān",
+ "纤": "xiān",
+ "俩": "liǎ",
+ "倆": "liǎ",
+ "亿": "yì",
+ "億": "yì",
+ "擦": "cā",
+ "盘": "pán",
+ "盤": "pán",
+ "茶": "chá",
+ "伯": "bó",
+ "免": "miǎn",
+ "弱": "ruò",
+ "征": "zhēng",
+ "遭": "zāo",
+ "控": "kòng",
+ "迅": "xùn",
+ "堂": "táng",
+ "岛": "dǎo",
+ "島": "dǎo",
+ "虎": "hǔ",
+ "鳥": "niǎo",
+ "鸟": "niǎo",
+ "鼻": "bí",
+ "齊": "qí",
+ "齐": "qí",
+ "忍": "rěn",
+ "灰": "huī",
+ "爆": "bào",
+ "威": "wēi",
+ "帽": "mào",
+ "毒": "dú",
+ "牲": "shēng",
+ "冒": "mào",
+ "牙": "yá",
+ "丝": "sī",
+ "液": "yè",
+ "絲": "sī",
+ "宽": "kuān",
+ "寬": "kuān",
+ "灵": "líng",
+ "靈": "líng",
+ "居": "jū",
+ "松": "sōng",
+ "訓": "xùn",
+ "训": "xùn",
+ "罪": "zuì",
+ "炮": "pào",
+ "粗": "cū",
+ "罵": "mà",
+ "膀": "bǎng",
+ "若": "ruò",
+ "骂": "mà",
+ "圈": "quān",
+ "孔": "kǒng",
+ "貴": "guì",
+ "贵": "guì",
+ "扬": "yáng",
+ "揚": "yáng",
+ "楼": "lóu",
+ "樓": "lóu",
+ "献": "xiàn",
+ "獻": "xiàn",
+ "縮": "suō",
+ "缩": "suō",
+ "份": "fèn",
+ "紡": "fǎng",
+ "纺": "fǎng",
+ "胸": "xiōng",
+ "輛": "liàng",
+ "辆": "liàng",
+ "途": "tú",
+ "炉": "lú",
+ "爐": "lú",
+ "渡": "dù",
+ "耳": "ěr",
+ "倾": "qīng",
+ "傾": "qīng",
+ "涂": "tú",
+ "票": "piào",
+ "菌": "jūn",
+ "壮": "zhuàng",
+ "壯": "zhuàng",
+ "播": "bō",
+ "械": "xiè",
+ "拖": "tuō",
+ "职": "zhí",
+ "職": "zhí",
+ "克": "kè",
+ "帐": "zhàng",
+ "帳": "zhàng",
+ "挤": "jǐ",
+ "擠": "jǐ",
+ "秋": "qiū",
+ "括": "kuò",
+ "索": "suǒ",
+ "肚": "dù",
+ "插": "chā",
+ "棵": "kē",
+ "湿": "shī",
+ "濕": "shī",
+ "謂": "wèi",
+ "谓": "wèi",
+ "麻": "má",
+ "尾": "wěi",
+ "阿": "ā",
+ "尖": "jiān",
+ "慌": "huāng",
+ "梁": "liáng",
+ "涌": "yǒng",
+ "盆": "pén",
+ "蛋": "dàn",
+ "趣": "qù",
+ "冰": "bīng",
+ "怒": "nù",
+ "咬": "yǎo",
+ "財": "cái",
+ "财": "cái",
+ "避": "bì",
+ "累": "lèi",
+ "辩": "biàn",
+ "辯": "biàn",
+ "曲": "qū",
+ "磨": "mó",
+ "逃": "táo",
+ "餓": "è",
+ "饿": "è",
+ "承": "chéng",
+ "疑": "yí",
+ "刺": "cì",
+ "探": "tàn",
+ "糊": "hú",
+ "肥": "féi",
+ "贊": "zàn",
+ "赞": "zàn",
+ "弯": "wān",
+ "彎": "wān",
+ "徒": "tú",
+ "香": "xiāng",
+ "付": "fù",
+ "腰": "yāo",
+ "愤": "fèn",
+ "憤": "fèn",
+ "扩": "kuò",
+ "擴": "kuò",
+ "暖": "nuǎn",
+ "吨": "dūn",
+ "噸": "dūn",
+ "阻": "zǔ",
+ "介": "jiè",
+ "柴": "chái",
+ "獲": "huò",
+ "紹": "shào",
+ "绍": "shào",
+ "获": "huò",
+ "藏": "cáng",
+ "緩": "huǎn",
+ "缓": "huǎn",
+ "隔": "gé",
+ "奔": "bēn",
+ "秘": "mì",
+ "偏": "piān",
+ "叹": "tàn",
+ "嘆": "tàn",
+ "窝": "wō",
+ "窩": "wō",
+ "净": "jìng",
+ "晨": "chén",
+ "淨": "jìng",
+ "稳": "wěn",
+ "穩": "wěn",
+ "詩": "shī",
+ "诗": "shī",
+ "喂": "wèi",
+ "暴": "bào",
+ "殖": "zhí",
+ "潮": "cháo",
+ "协": "xié",
+ "協": "xié",
+ "登": "dēng",
+ "迷": "mí",
+ "壁": "bì",
+ "毕": "bì",
+ "畢": "bì",
+ "浮": "fú",
+ "紛": "fēn",
+ "纷": "fēn",
+ "闊": "kuò",
+ "阔": "kuò",
+ "阴": "yīn",
+ "附": "fù",
+ "陰": "yīn",
+ "井": "jǐng",
+ "哼": "hēng",
+ "巧": "qiǎo",
+ "拼": "pīn",
+ "榮": "róng",
+ "滚": "gǔn",
+ "滾": "gǔn",
+ "荣": "róng",
+ "厉": "lì",
+ "厲": "lì",
+ "异": "yì",
+ "異": "yì",
+ "麥": "mài",
+ "麦": "mài",
+ "寒": "hán",
+ "惯": "guàn",
+ "慣": "guàn",
+ "谷": "gǔ",
+ "丟": "diū",
+ "丢": "diū",
+ "培": "péi",
+ "宇": "yǔ",
+ "泛": "fàn",
+ "肃": "sù",
+ "肅": "sù",
+ "載": "zài",
+ "载": "zài",
+ "录": "lù",
+ "舒": "shū",
+ "錄": "lù",
+ "健": "jiàn",
+ "婆": "pó",
+ "搬": "bān",
+ "禁": "jìn",
+ "寻": "xún",
+ "尋": "xún",
+ "灌": "guàn",
+ "补": "bǔ",
+ "補": "bǔ",
+ "駝": "tuó",
+ "驼": "tuó",
+ "促": "cù",
+ "刷": "shuā",
+ "扑": "pū",
+ "撲": "pū",
+ "析": "xī",
+ "珠": "zhū",
+ "愈": "yù",
+ "旅": "lǚ",
+ "跃": "yuè",
+ "躍": "yuè",
+ "凝": "níng",
+ "彩": "cǎi",
+ "拔": "bá",
+ "袖": "xiù",
+ "幕": "mù",
+ "庭": "tíng",
+ "戴": "dài",
+ "援": "yuán",
+ "航": "háng",
+ "呆": "dāi",
+ "挖": "wā",
+ "杆": "gān",
+ "沟": "gōu",
+ "溝": "gōu",
+ "猿": "yuán",
+ "瓜": "guā",
+ "凡": "fán",
+ "吓": "xià",
+ "嚇": "xià",
+ "迎": "yíng",
+ "凭": "píng",
+ "憑": "píng",
+ "扫": "sǎo",
+ "掃": "sǎo",
+ "騎": "qí",
+ "骑": "qí",
+ "冻": "dòng",
+ "凍": "dòng",
+ "扎": "zhā",
+ "操": "cāo",
+ "箱": "xiāng",
+ "純": "chún",
+ "纯": "chún",
+ "聞": "wén",
+ "闻": "wén",
+ "仔": "zǐ",
+ "績": "jī",
+ "绩": "jì",
+ "訊": "xùn",
+ "讯": "xùn",
+ "踏": "tà",
+ "顏": "yán",
+ "颜": "yán",
+ "序": "xù",
+ "恨": "hèn",
+ "抢": "qiǎng",
+ "搶": "qiǎng",
+ "横": "héng",
+ "橫": "héng",
+ "疯": "fēng",
+ "瘋": "fēng",
+ "眉": "méi",
+ "宙": "zhòu",
+ "凉": "liáng",
+ "卷": "juǎn",
+ "夢": "mèng",
+ "梦": "mèng",
+ "氧": "yǎng",
+ "涼": "liáng",
+ "繁": "fán",
+ "距": "jù",
+ "銅": "tóng",
+ "铜": "tóng",
+ "仗": "zhàng",
+ "割": "gē",
+ "损": "sǔn",
+ "損": "sǔn",
+ "摄": "shè",
+ "摔": "shuāi",
+ "攝": "shè",
+ "瓶": "píng",
+ "悲": "bēi",
+ "昏": "hūn",
+ "疼": "téng",
+ "繩": "shéng",
+ "绳": "shéng",
+ "豆": "dòu",
+ "烂": "làn",
+ "烦": "fán",
+ "煩": "fán",
+ "爛": "làn",
+ "蓝": "lán",
+ "藍": "lán",
+ "訂": "dìng",
+ "订": "dìng",
+ "侧": "cè",
+ "側": "cè",
+ "巩": "gǒng",
+ "慮": "lǜ",
+ "虑": "lǜ",
+ "軟": "ruǎn",
+ "软": "ruǎn",
+ "鞏": "gǒng",
+ "匆": "cōng",
+ "域": "yù",
+ "尺": "chǐ",
+ "貼": "tiē",
+ "賽": "sài",
+ "贴": "tiē",
+ "赛": "sài",
+ "躲": "duǒ",
+ "剧": "jù",
+ "劇": "jù",
+ "役": "yì",
+ "恰": "qià",
+ "惟": "wéi",
+ "狠": "hěn",
+ "薄": "báo",
+ "释": "shì",
+ "釋": "shì",
+ "駛": "shǐ",
+ "驶": "shǐ",
+ "俺": "ǎn",
+ "兄": "xiōng",
+ "尊": "zūn",
+ "幅": "fú",
+ "拥": "yōng",
+ "授": "shòu",
+ "擁": "yōng",
+ "杯": "bēi",
+ "謀": "móu",
+ "谋": "móu",
+ "劝": "quàn",
+ "勸": "quàn",
+ "博": "bó",
+ "仪": "yí",
+ "儀": "yí",
+ "捧": "pěng",
+ "睁": "zhēng",
+ "睜": "zhēng",
+ "網": "wǎng",
+ "网": "wǎng",
+ "触": "chù",
+ "觸": "chù",
+ "腾": "téng",
+ "騰": "téng",
+ "匪": "fěi",
+ "夹": "jiā",
+ "夾": "jiā",
+ "抖": "dǒu",
+ "揭": "jiē",
+ "稍": "shāo",
+ "稼": "jià",
+ "腐": "fǔ",
+ "閉": "bì",
+ "闭": "bì",
+ "浓": "nóng",
+ "濃": "nóng",
+ "胞": "bāo",
+ "脈": "mài",
+ "脉": "mài",
+ "駱": "luò",
+ "骆": "luò",
+ "刑": "xíng",
+ "惜": "xī",
+ "皱": "zhòu",
+ "皺": "zhòu",
+ "监": "jiān",
+ "監": "jiān",
+ "脏": "zàng",
+ "臟": "zàng",
+ "蒸": "zhēng",
+ "貧": "pín",
+ "贫": "pín",
+ "鍋": "guō",
+ "锅": "guō",
+ "波": "bō",
+ "炸": "zhà",
+ "礼": "lǐ",
+ "禮": "lǐ",
+ "私": "sī",
+ "繞": "rào",
+ "绕": "rào",
+ "塑": "sù",
+ "磁": "cí",
+ "违": "wéi",
+ "違": "wéi",
+ "丈": "zhàng",
+ "玉": "yù",
+ "茫": "máng",
+ "吐": "tǔ",
+ "喷": "pēn",
+ "噴": "pēn",
+ "废": "fèi",
+ "廢": "fèi",
+ "怜": "lián",
+ "恢": "huī",
+ "悉": "xī",
+ "憐": "lián",
+ "挨": "āi",
+ "敲": "qiāo",
+ "淡": "dàn",
+ "尤": "yóu",
+ "忆": "yì",
+ "憶": "yì",
+ "災": "zāi",
+ "灾": "zāi",
+ "蜜": "mì",
+ "啥": "shá",
+ "恐": "kǒng",
+ "述": "shù",
+ "隐": "yǐn",
+ "隱": "yǐn",
+ "残": "cán",
+ "殘": "cán",
+ "額": "é",
+ "额": "é",
+ "亩": "mǔ",
+ "旋": "xuán",
+ "污": "wū",
+ "甲": "jiǎ",
+ "畝": "mǔ",
+ "胆": "dǎn",
+ "膽": "dǎn",
+ "蹲": "dūn",
+ "迟": "chí",
+ "遲": "chí",
+ "乘": "chéng",
+ "伴": "bàn",
+ "掏": "tāo",
+ "縫": "fèng",
+ "缝": "fèng",
+ "刮": "guā",
+ "椅": "yǐ",
+ "串": "chuàn",
+ "埋": "mái",
+ "抵": "dǐ",
+ "捉": "zhuō",
+ "秒": "miǎo",
+ "乏": "fá",
+ "喔": "ō",
+ "噢": "ō",
+ "坡": "pō",
+ "捕": "bǔ",
+ "添": "tiān",
+ "牺": "xī",
+ "犧": "xī",
+ "粒": "lì",
+ "舍": "shě",
+ "允": "yǔn",
+ "哇": "wa",
+ "柜": "guì",
+ "酸": "suān",
+ "寄": "jì",
+ "扔": "rēng",
+ "托": "tuō",
+ "措": "cuò",
+ "狂": "kuáng",
+ "遗": "yí",
+ "遺": "yí",
+ "伏": "fú",
+ "兔": "tù",
+ "勤": "qín",
+ "珍": "zhēn",
+ "糟": "zāo",
+ "輝": "huī",
+ "辉": "huī",
+ "拾": "shí",
+ "殊": "shū",
+ "浑": "hún",
+ "渾": "hún",
+ "滴": "dī",
+ "典": "diǎn",
+ "漠": "mò",
+ "猜": "cāi",
+ "障": "zhàng",
+ "唇": "chún",
+ "壳": "ké",
+ "峡": "xiá",
+ "峽": "xiá",
+ "德": "dé",
+ "忿": "fèn",
+ "撞": "zhuàng",
+ "棒": "bàng",
+ "殼": "ké",
+ "滑": "huá",
+ "牵": "qiān",
+ "牽": "qiān",
+ "盛": "shèng",
+ "糖": "táng",
+ "貢": "gòng",
+ "贡": "gòng",
+ "哟": "yō",
+ "喲": "yō",
+ "宜": "yí",
+ "敬": "jìng",
+ "斜": "xié",
+ "暂": "zàn",
+ "暫": "zàn",
+ "歼": "jiān",
+ "殲": "jiān",
+ "竹": "zhú",
+ "笼": "lóng",
+ "籠": "lóng",
+ "聪": "cōng",
+ "聰": "cōng",
+ "蜂": "fēng",
+ "騙": "piàn",
+ "骗": "piàn",
+ "扭": "niǔ",
+ "詳": "xiáng",
+ "详": "xiáng",
+ "貌": "mào",
+ "辟": "pì",
+ "亡": "wáng",
+ "峰": "fēng",
+ "励": "lì",
+ "勵": "lì",
+ "归": "guī",
+ "歸": "guī",
+ "焊": "hàn",
+ "秀": "xiù",
+ "唤": "huàn",
+ "喚": "huàn",
+ "寸": "cùn",
+ "毀": "huǐ",
+ "毁": "huǐ",
+ "稻": "dào",
+ "緒": "xù",
+ "绪": "xù",
+ "脆": "cuì",
+ "銷": "xiāo",
+ "销": "xiāo",
+ "库": "kù",
+ "庫": "kù",
+ "渠": "qú",
+ "爹": "diē",
+ "祝": "zhù",
+ "貫": "guàn",
+ "贯": "guàn",
+ "雷": "léi",
+ "坑": "kēng",
+ "蒙": "méng",
+ "辛": "xīn",
+ "遵": "zūn",
+ "飄": "piāo",
+ "飘": "piāo",
+ "婚": "hūn",
+ "披": "pī",
+ "胃": "wèi",
+ "趟": "tàng",
+ "逼": "bī",
+ "閑": "xián",
+ "闲": "xián",
+ "嚷": "rǎng",
+ "垂": "chuí",
+ "塞": "sāi",
+ "娃": "wá",
+ "扯": "chě",
+ "狼": "láng",
+ "鍛": "duàn",
+ "锻": "duàn",
+ "凳": "dèng",
+ "卵": "luǎn",
+ "炕": "kàng",
+ "箭": "jiàn",
+ "肤": "fū",
+ "膚": "fū",
+ "跡": "jī",
+ "輩": "bèi",
+ "辈": "bèi",
+ "迹": "jì",
+ "匠": "jiàng",
+ "巾": "jīn",
+ "洁": "jié",
+ "涨": "zhǎng",
+ "漲": "zhǎng",
+ "潔": "jié",
+ "猴": "hóu",
+ "耗": "hào",
+ "臂": "bì",
+ "虚": "xū",
+ "虛": "xū",
+ "陷": "xiàn",
+ "吵": "chǎo",
+ "咳": "hāi",
+ "搭": "dā",
+ "森": "sēn",
+ "漂": "piào",
+ "狱": "yù",
+ "獄": "yù",
+ "疗": "liáo",
+ "療": "liáo",
+ "皇": "huáng",
+ "翅": "chì",
+ "脾": "pí",
+ "鈴": "líng",
+ "铃": "líng",
+ "雾": "wù",
+ "霧": "wù",
+ "飽": "bǎo",
+ "饱": "bǎo",
+ "尚": "shàng",
+ "拣": "jiǎn",
+ "振": "zhèn",
+ "掩": "yǎn",
+ "揀": "jiǎn",
+ "歇": "xiē",
+ "牧": "mù",
+ "番": "fān",
+ "符": "fú",
+ "趁": "chèn",
+ "挡": "dǎng",
+ "擋": "dǎng",
+ "晓": "xiǎo",
+ "曉": "xiǎo",
+ "猪": "zhū",
+ "綱": "gāng",
+ "纲": "gāng",
+ "舅": "jiù",
+ "豬": "zhū",
+ "迈": "mài",
+ "递": "dì",
+ "遞": "dì",
+ "邁": "mài",
+ "壤": "rǎng",
+ "撤": "chè",
+ "浅": "qiǎn",
+ "淺": "qiǎn",
+ "瘦": "shòu",
+ "肠": "cháng",
+ "腸": "cháng",
+ "塘": "táng",
+ "塵": "chén",
+ "妙": "miào",
+ "尘": "chén",
+ "砍": "kǎn",
+ "碑": "bēi",
+ "焦": "jiāo",
+ "衡": "héng",
+ "齒": "chǐ",
+ "齿": "chǐ",
+ "剂": "jì",
+ "剑": "jiàn",
+ "劍": "jiàn",
+ "劑": "jì",
+ "匹": "pǐ",
+ "摘": "zhāi",
+ "竞": "jìng",
+ "競": "jìng",
+ "凶": "xiōng",
+ "售": "shòu",
+ "堵": "dǔ",
+ "康": "kāng",
+ "拱": "gǒng",
+ "漆": "qī",
+ "疲": "pí",
+ "盒": "hé",
+ "紗": "shā",
+ "纱": "shā",
+ "坦": "tǎn",
+ "斯": "sī",
+ "杨": "yáng",
+ "楊": "yáng",
+ "泡": "pào",
+ "盟": "méng",
+ "瞪": "dèng",
+ "緣": "yuán",
+ "缘": "yuán",
+ "苹": "píng",
+ "蘋": "píng",
+ "轟": "hōng",
+ "轰": "hōng",
+ "逗": "dòu",
+ "享": "xiǎng",
+ "喘": "chuǎn",
+ "嘿": "hēi",
+ "挣": "zhēng",
+ "掙": "zhēng",
+ "棚": "péng",
+ "签": "qiān",
+ "簽": "qiān",
+ "龍": "lóng",
+ "龙": "lóng",
+ "宿": "sù",
+ "悶": "mèn",
+ "泼": "pō",
+ "溉": "gài",
+ "潑": "pō",
+ "甜": "tián",
+ "舱": "cāng",
+ "艙": "cāng",
+ "闷": "mèn",
+ "餅": "bǐng",
+ "饼": "bǐng",
+ "愉": "yú",
+ "捏": "niē",
+ "棍": "gùn",
+ "盼": "pàn",
+ "篮": "lán",
+ "籃": "lán",
+ "芦": "lú",
+ "蘆": "lú",
+ "鉛": "qiān",
+ "铅": "qiān",
+ "匯": "huì",
+ "奴": "nú",
+ "宫": "gōng",
+ "宮": "gōng",
+ "汇": "huì",
+ "炭": "tàn",
+ "版": "bǎn",
+ "牌": "pái",
+ "窑": "yáo",
+ "窯": "yáo",
+ "聚": "jù",
+ "脖": "bó",
+ "訪": "fǎng",
+ "访": "fǎng",
+ "隶": "lì",
+ "隸": "lì",
+ "咐": "fù",
+ "摊": "tān",
+ "攤": "tān",
+ "昆": "kūn",
+ "桶": "tǒng",
+ "池": "chí",
+ "猎": "liè",
+ "獵": "liè",
+ "碍": "ài",
+ "礙": "ài",
+ "臭": "chòu",
+ "詞": "cí",
+ "词": "cí",
+ "軌": "guǐ",
+ "轨": "guǐ",
+ "釣": "diào",
+ "钓": "diào",
+ "顫": "chàn",
+ "颤": "chàn",
+ "亏": "kuī",
+ "仇": "chóu",
+ "择": "zé",
+ "擇": "zé",
+ "智": "zhì",
+ "苗": "miáo",
+ "虧": "kuī",
+ "鋒": "fēng",
+ "锋": "fēng",
+ "仰": "yǎng",
+ "屆": "jiè",
+ "届": "jiè",
+ "岗": "gǎng",
+ "岩": "yán",
+ "岭": "lǐng",
+ "崗": "gǎng",
+ "嶺": "lǐng",
+ "慰": "wèi",
+ "抄": "chāo",
+ "盐": "yán",
+ "譯": "yì",
+ "译": "yì",
+ "鹽": "yán",
+ "丛": "cóng",
+ "乌": "wū",
+ "凑": "còu",
+ "厘": "lí",
+ "叢": "cóng",
+ "奖": "jiǎng",
+ "妻": "qī",
+ "径": "jìng",
+ "徑": "jìng",
+ "悟": "wù",
+ "欠": "qiàn",
+ "湊": "còu",
+ "烏": "wū",
+ "獎": "jiǎng",
+ "苍": "cāng",
+ "荷": "hé",
+ "蒼": "cāng",
+ "輯": "jí",
+ "辑": "jí",
+ "陪": "péi",
+ "叛": "pàn",
+ "捞": "lāo",
+ "撈": "lāo",
+ "撒": "sā",
+ "柱": "zhù",
+ "株": "zhū",
+ "核": "hé",
+ "润": "rùn",
+ "漏": "lòu",
+ "潤": "rùn",
+ "瓷": "cí",
+ "糾": "jiū",
+ "纠": "jiū",
+ "蛇": "shé",
+ "鎖": "suǒ",
+ "锁": "suǒ",
+ "估": "gū",
+ "傲": "ào",
+ "厌": "yàn",
+ "厭": "yàn",
+ "宗": "zōng",
+ "扶": "fú",
+ "捆": "kǔn",
+ "荡": "dàng",
+ "蕩": "dàng",
+ "蚀": "shí",
+ "蝕": "shí",
+ "裂": "liè",
+ "驕": "jiāo",
+ "骄": "jiāo",
+ "幼": "yòu",
+ "拨": "bō",
+ "挽": "wǎn",
+ "掀": "xiān",
+ "撥": "bō",
+ "銳": "ruì",
+ "锐": "ruì",
+ "鳴": "míng",
+ "鸣": "míng",
+ "款": "kuǎn",
+ "盯": "dīng",
+ "胳": "gē",
+ "偶": "ǒu",
+ "寂": "jì",
+ "屈": "qū",
+ "恳": "kěn",
+ "懇": "kěn",
+ "晃": "huǎng",
+ "歪": "wāi",
+ "眯": "mī",
+ "瞇": "mī",
+ "秧": "yāng",
+ "稿": "gǎo",
+ "綜": "zōng",
+ "综": "zōng",
+ "踩": "cǎi",
+ "鯨": "jīng",
+ "鲸": "jīng",
+ "吼": "hǒu",
+ "嗓": "sǎng",
+ "扁": "biǎn",
+ "朴": "pǔ",
+ "欣": "xīn",
+ "莫": "mò",
+ "傻": "shǎ",
+ "幻": "huàn",
+ "扣": "kòu",
+ "拢": "lǒng",
+ "掠": "lüè",
+ "攏": "lǒng",
+ "榴": "liú",
+ "溶": "róng",
+ "滩": "tān",
+ "灘": "tān",
+ "牢": "láo",
+ "猫": "māo",
+ "腔": "qiāng",
+ "蚕": "cán",
+ "蝗": "huáng",
+ "蠶": "cán",
+ "裤": "kù",
+ "褲": "kù",
+ "貓": "māo",
+ "跨": "kuà",
+ "霜": "shuāng",
+ "冶": "yě",
+ "咽": "yàn",
+ "宅": "zhái",
+ "搜": "sōu",
+ "晴": "qíng",
+ "遮": "zhē",
+ "启": "qǐ",
+ "啟": "qǐ",
+ "彼": "bǐ",
+ "抹": "mǒ",
+ "搁": "gē",
+ "擱": "gē",
+ "敏": "mǐn",
+ "漫": "màn",
+ "码": "mǎ",
+ "碼": "mǎ",
+ "筋": "jīn",
+ "鍵": "jiàn",
+ "键": "jiàn",
+ "厅": "tīng",
+ "吊": "diào",
+ "廳": "tīng",
+ "拒": "jù",
+ "旱": "hàn",
+ "桃": "táo",
+ "欺": "qī",
+ "燕": "yàn",
+ "琴": "qín",
+ "舌": "shé",
+ "蔽": "bì",
+ "袄": "ǎo",
+ "襖": "ǎo",
+ "釘": "dīng",
+ "钉": "dīng",
+ "駕": "jià",
+ "驾": "jià",
+ "丘": "qiū",
+ "审": "shěn",
+ "審": "shěn",
+ "币": "bì",
+ "幣": "bì",
+ "愣": "lèng",
+ "拦": "lán",
+ "摧": "cuī",
+ "撕": "sī",
+ "攔": "lán",
+ "浇": "jiāo",
+ "澆": "jiāo",
+ "賞": "shǎng",
+ "赏": "shǎng",
+ "鴉": "yā",
+ "鸦": "yā",
+ "伞": "sǎn",
+ "傘": "sǎn",
+ "晶": "jīng",
+ "涉": "shè",
+ "犹": "yóu",
+ "猶": "yóu",
+ "蛙": "wā",
+ "丫": "yā",
+ "僚": "liáo",
+ "哏": "gén",
+ "嗡": "wēng",
+ "宪": "xiàn",
+ "憲": "xiàn",
+ "描": "miáo",
+ "朗": "lǎng",
+ "柔": "róu",
+ "橘": "jú",
+ "瞎": "xiā",
+ "稀": "xī",
+ "肝": "gān",
+ "裳": "shang",
+ "隆": "lóng",
+ "頑": "wán",
+ "顽": "wán",
+ "驢": "lǘ",
+ "驴": "lǘ",
+ "倡": "chàng",
+ "哀": "āi",
+ "堤": "dī",
+ "姨": "yí",
+ "崇": "chóng",
+ "庙": "miào",
+ "廟": "miào",
+ "延": "yán",
+ "汤": "tāng",
+ "湯": "tāng",
+ "碳": "tàn",
+ "童": "tóng",
+ "耕": "gēng",
+ "跪": "guì",
+ "辫": "biàn",
+ "辮": "biàn",
+ "闖": "chuǎng",
+ "闯": "chuǎng",
+ "頗": "pō",
+ "颇": "pō",
+ "勃": "bó",
+ "哗": "huā",
+ "嘩": "huā",
+ "嫁": "jià",
+ "孤": "gū",
+ "拳": "quán",
+ "晒": "shài",
+ "栽": "zāi",
+ "洒": "sǎ",
+ "耀": "yào",
+ "胀": "zhàng",
+ "胁": "xié",
+ "脅": "xié",
+ "脹": "zhàng",
+ "膜": "mó",
+ "荒": "huāng",
+ "亭": "tíng",
+ "咧": "liě",
+ "填": "tián",
+ "妥": "tuǒ",
+ "帘": "lián",
+ "患": "huàn",
+ "截": "jié",
+ "抑": "yì",
+ "攀": "pān",
+ "梅": "méi",
+ "烛": "zhú",
+ "燭": "zhú",
+ "督": "dū",
+ "逢": "féng",
+ "魔": "mó",
+ "伐": "fá",
+ "媳": "xí",
+ "悬": "xuán",
+ "懸": "xuán",
+ "戚": "qī",
+ "煮": "zhǔ",
+ "盗": "dào",
+ "盜": "dào",
+ "綁": "bǎng",
+ "绑": "bǎng",
+ "肺": "fèi",
+ "侦": "zhēn",
+ "俗": "sú",
+ "偵": "zhēn",
+ "哨": "shào",
+ "喉": "hóu",
+ "岂": "qǐ",
+ "帜": "zhì",
+ "幟": "zhì",
+ "庆": "qìng",
+ "弃": "qì",
+ "惨": "cǎn",
+ "慘": "cǎn",
+ "慶": "qìng",
+ "抛": "pāo",
+ "拋": "pāo",
+ "末": "mò",
+ "棄": "qì",
+ "浸": "jìn",
+ "港": "gǎng",
+ "眨": "zhǎ",
+ "租": "zū",
+ "窜": "cuàn",
+ "竄": "cuàn",
+ "誠": "chéng",
+ "诚": "chéng",
+ "豈": "qǐ",
+ "醉": "zuì",
+ "刊": "kān",
+ "墨": "mò",
+ "桩": "zhuāng",
+ "樁": "zhuāng",
+ "炎": "yán",
+ "盏": "zhǎn",
+ "盞": "zhǎn",
+ "肖": "xiào",
+ "踢": "tī",
+ "錦": "jǐn",
+ "锦": "jǐn",
+ "啪": "pā",
+ "塔": "tǎ",
+ "惹": "rě",
+ "柳": "liǔ",
+ "筐": "kuāng",
+ "紫": "zǐ",
+ "罩": "zhào",
+ "萄": "táo",
+ "葡": "pú",
+ "貝": "bèi",
+ "贝": "bèi",
+ "辨": "biàn",
+ "顛": "diān",
+ "颠": "diān",
+ "伪": "wěi",
+ "偽": "wěi",
+ "冤": "yuān",
+ "厨": "chú",
+ "吩": "fēn",
+ "妄": "wàng",
+ "姿": "zī",
+ "屁": "pì",
+ "廚": "chú",
+ "愁": "chóu",
+ "晌": "shǎng",
+ "渴": "kě",
+ "溜": "liū",
+ "甩": "shuǎi",
+ "眠": "mián",
+ "粥": "zhōu",
+ "綸": "lún",
+ "纶": "lún",
+ "蝉": "chán",
+ "蟬": "chán",
+ "覽": "lǎn",
+ "览": "lǎn",
+ "返": "fǎn",
+ "閥": "fá",
+ "阀": "fá",
+ "鞭": "biān",
+ "頻": "pín",
+ "频": "pín",
+ "仓": "cāng",
+ "倉": "cāng",
+ "傍": "bàng",
+ "壶": "hú",
+ "壺": "hú",
+ "怨": "yuàn",
+ "汗": "hàn",
+ "泉": "quán",
+ "窄": "zhǎi",
+ "紋": "wén",
+ "纹": "wén",
+ "跌": "diē",
+ "喽": "lóu",
+ "嘍": "lóu",
+ "坟": "fén",
+ "墳": "fén",
+ "扛": "káng",
+ "扮": "bàn",
+ "洪": "hóng",
+ "瓦": "wǎ",
+ "秩": "zhì",
+ "脂": "zhī",
+ "虾": "xiā",
+ "蝦": "xiā",
+ "衬": "chèn",
+ "袭": "xí",
+ "裹": "guǒ",
+ "襯": "chèn",
+ "襲": "xí",
+ "諒": "liàng",
+ "谅": "liàng",
+ "魂": "hún",
+ "乙": "yǐ",
+ "倘": "tǎng",
+ "卧": "wò",
+ "矮": "ǎi",
+ "筒": "tǒng",
+ "膊": "bó",
+ "臥": "wò",
+ "揉": "róu",
+ "昂": "áng",
+ "栏": "lán",
+ "欄": "lán",
+ "疾": "jí",
+ "痕": "hén",
+ "砖": "zhuān",
+ "磚": "zhuān",
+ "膨": "péng",
+ "餐": "cān",
+ "兜": "dōu",
+ "夸": "kuā",
+ "崖": "yá",
+ "拆": "chāi",
+ "斧": "fǔ",
+ "欲": "yù",
+ "沫": "mò",
+ "涡": "wō",
+ "渦": "wō",
+ "縱": "zòng",
+ "纵": "zòng",
+ "肌": "jī",
+ "胖": "pàng",
+ "趴": "pā",
+ "飲": "yǐn",
+ "饮": "yǐn",
+ "齡": "líng",
+ "龄": "líng",
+ "丹": "dān",
+ "勾": "gōu",
+ "嘻": "xī",
+ "御": "yù",
+ "戒": "jiè",
+ "拴": "shuān",
+ "撐": "chēng",
+ "撑": "chēng",
+ "朽": "xiǔ",
+ "甘": "gān",
+ "袜": "wà",
+ "袱": "fú",
+ "裁": "cái",
+ "襪": "wà",
+ "譬": "pì",
+ "鉤": "gōu",
+ "鋁": "lǚ",
+ "钩": "gōu",
+ "铝": "lǚ",
+ "鼠": "shǔ",
+ "催": "cuī",
+ "咦": "yí",
+ "拧": "níng",
+ "搅": "jiǎo",
+ "擰": "níng",
+ "攪": "jiǎo",
+ "淹": "yān",
+ "渔": "yú",
+ "漁": "yú",
+ "熊": "xióng",
+ "盲": "máng",
+ "筷": "kuài",
+ "緯": "wěi",
+ "纬": "wěi",
+ "購": "gòu",
+ "购": "gòu",
+ "鴨": "yā",
+ "鸭": "yā",
+ "予": "yǔ",
+ "兼": "jiān",
+ "兽": "shòu",
+ "呈": "chéng",
+ "哄": "hōng",
+ "娶": "qǔ",
+ "恆": "héng",
+ "恒": "héng",
+ "慧": "huì",
+ "梯": "tī",
+ "殿": "diàn",
+ "氏": "shì",
+ "淋": "lín",
+ "溪": "xī",
+ "獸": "shòu",
+ "罐": "guàn",
+ "蚁": "yǐ",
+ "蚂": "mǎ",
+ "蜡": "là",
+ "螞": "mǎ",
+ "蟻": "yǐ",
+ "蠟": "là",
+ "誕": "dàn",
+ "诞": "dàn",
+ "逮": "dǎi",
+ "飾": "shì",
+ "饰": "shì",
+ "剪": "jiǎn",
+ "叠": "dié",
+ "嗽": "sòu",
+ "悔": "huǐ",
+ "槽": "cáo",
+ "疊": "dié",
+ "碧": "bì",
+ "繪": "huì",
+ "绘": "huì",
+ "耸": "sǒng",
+ "聳": "sǒng",
+ "蝇": "yíng",
+ "蠅": "yíng",
+ "豫": "yù",
+ "蹬": "dēng",
+ "軸": "zhóu",
+ "轴": "zhóu",
+ "叮": "dīng",
+ "嘗": "cháng",
+ "圾": "jī",
+ "垃": "lā",
+ "垮": "kuǎ",
+ "尝": "cháng",
+ "慎": "shèn",
+ "沾": "zhān",
+ "潛": "qián",
+ "潜": "qián",
+ "皂": "zào",
+ "窃": "qiè",
+ "竊": "qiè",
+ "缸": "gāng",
+ "肢": "zhī",
+ "胎": "tāi",
+ "脊": "jǐ",
+ "膝": "xī",
+ "艳": "yàn",
+ "艷": "yàn",
+ "詫": "chà",
+ "诧": "chà",
+ "酷": "kù",
+ "雕": "diāo",
+ "霉": "méi",
+ "冈": "gāng",
+ "勉": "miǎn",
+ "吆": "yāo",
+ "嫌": "xián",
+ "岡": "gāng",
+ "巷": "xiàng",
+ "愧": "kuì",
+ "拌": "bàn",
+ "揪": "jiū",
+ "晰": "xī",
+ "泊": "pō",
+ "灿": "càn",
+ "燦": "càn",
+ "瓣": "bàn",
+ "症": "zhèng",
+ "胶": "jiāo",
+ "膠": "jiāo",
+ "豁": "huō",
+ "踱": "duó",
+ "閨": "guī",
+ "闺": "guī",
+ "隙": "xì",
+ "飢": "jī",
+ "饅": "mán",
+ "饥": "jī",
+ "馒": "mán",
+ "债": "zhài",
+ "債": "zhài",
+ "唰": "shuā",
+ "墩": "dūn",
+ "弓": "gōng",
+ "恥": "chǐ",
+ "旦": "dàn",
+ "李": "lǐ",
+ "烤": "kǎo",
+ "熄": "xī",
+ "砸": "zá",
+ "粪": "fèn",
+ "糞": "fèn",
+ "耻": "chǐ",
+ "誉": "yù",
+ "譽": "yù",
+ "貿": "mào",
+ "贸": "mào",
+ "酱": "jiàng",
+ "醬": "jiàng",
+ "鑄": "zhù",
+ "铸": "zhù",
+ "飼": "sì",
+ "饲": "sì",
+ "亦": "yì",
+ "仙": "xiān",
+ "哧": "chī",
+ "嘱": "zhǔ",
+ "囑": "zhǔ",
+ "妨": "fáng",
+ "婴": "yīng",
+ "嬰": "yīng",
+ "寞": "mò",
+ "押": "yā",
+ "斥": "chì",
+ "框": "kuàng",
+ "爽": "shuǎng",
+ "甭": "béng",
+ "畜": "chù",
+ "癌": "ái",
+ "硫": "liú",
+ "笨": "bèn",
+ "籍": "jí",
+ "芒": "máng",
+ "蝴": "hú",
+ "蝶": "dié",
+ "袍": "páo",
+ "豪": "háo",
+ "邻": "lín",
+ "鄰": "lín",
+ "頁": "yè",
+ "页": "yè",
+ "馳": "chí",
+ "驰": "chí",
+ "倚": "yǐ",
+ "僵": "jiāng",
+ "凿": "záo",
+ "勻": "yún",
+ "匀": "yún",
+ "君": "jūn",
+ "宴": "yàn",
+ "宵": "xiāo",
+ "崭": "zhǎn",
+ "嶄": "zhǎn",
+ "扇": "shàn",
+ "枕": "zhěn",
+ "枯": "kū",
+ "渗": "shèn",
+ "滲": "shèn",
+ "焰": "yàn",
+ "瞅": "chǒu",
+ "縛": "fù",
+ "缚": "fù",
+ "蛛": "zhū",
+ "蜘": "zhī",
+ "赤": "chì",
+ "迁": "qiān",
+ "遷": "qiān",
+ "鑿": "záo",
+ "埃": "āi",
+ "慨": "kǎi",
+ "挫": "cuò",
+ "淘": "táo",
+ "渣": "zhā",
+ "砂": "shā",
+ "耽": "dān",
+ "苏": "sū",
+ "蔬": "shū",
+ "蘇": "sū",
+ "訝": "yà",
+ "讶": "yà",
+ "躁": "zào",
+ "鉴": "jiàn",
+ "鑒": "jiàn",
+ "雀": "què",
+ "駐": "zhù",
+ "驻": "zhù",
+ "侮": "wǔ",
+ "吁": "xū",
+ "呸": "pēi",
+ "啾": "jiū",
+ "塌": "tā",
+ "循": "xún",
+ "怖": "bù",
+ "扰": "rǎo",
+ "擾": "rǎo",
+ "朦": "méng",
+ "朧": "lóng",
+ "燥": "zào",
+ "瞒": "mán",
+ "瞞": "mán",
+ "纏": "chán",
+ "缠": "chán",
+ "胧": "lóng",
+ "苇": "wěi",
+ "葦": "wěi",
+ "診": "zhěn",
+ "诊": "zhěn",
+ "辽": "liáo",
+ "遼": "liáo",
+ "邮": "yóu",
+ "郵": "yóu",
+ "陌": "mò",
+ "丑": "chǒu",
+ "俘": "fú",
+ "凸": "tū",
+ "凹": "āo",
+ "刹": "shā",
+ "剎": "shā",
+ "嗨": "hāi",
+ "宏": "hóng",
+ "懒": "lǎn",
+ "懶": "lǎn",
+ "扒": "bā",
+ "梢": "shāo",
+ "狭": "xiá",
+ "狹": "xiá",
+ "碱": "jiǎn",
+ "芽": "yá",
+ "虏": "lǔ",
+ "虜": "lǔ",
+ "賊": "zéi",
+ "贼": "zéi",
+ "輔": "fǔ",
+ "輻": "fú",
+ "辅": "fǔ",
+ "辐": "fú",
+ "逝": "shì",
+ "陡": "dǒu",
+ "陵": "líng",
+ "頌": "sòng",
+ "颂": "sòng",
+ "鹼": "jiǎn",
+ "黏": "nián",
+ "丙": "bǐng",
+ "吞": "tūn",
+ "哆": "duō",
+ "嗦": "suō",
+ "夕": "xī",
+ "屿": "yǔ",
+ "嶼": "yǔ",
+ "州": "zhōu",
+ "梳": "shū",
+ "汹": "xiōng",
+ "洶": "xiōng",
+ "浊": "zhuó",
+ "淀": "diàn",
+ "澱": "diàn",
+ "濁": "zhuó",
+ "烫": "tàng",
+ "燙": "tàng",
+ "爪": "zhǎo",
+ "竖": "shù",
+ "竿": "gān",
+ "繃": "běng",
+ "绷": "bēng",
+ "翘": "qiào",
+ "翹": "qiào",
+ "艘": "sōu",
+ "蚊": "wén",
+ "誒": "éi",
+ "誼": "yì",
+ "诶": "éi",
+ "谊": "yì",
+ "豎": "shù",
+ "貪": "tān",
+ "贪": "tān",
+ "遣": "qiǎn",
+ "邀": "yāo",
+ "黎": "lí",
+ "乒": "pīng",
+ "乓": "pāng",
+ "佩": "pèi",
+ "华": "huá",
+ "堪": "kān",
+ "昼": "zhòu",
+ "晝": "zhòu",
+ "柄": "bǐng",
+ "橡": "xiàng",
+ "毯": "tǎn",
+ "潭": "tán",
+ "烁": "shuò",
+ "爍": "shuò",
+ "矩": "jǔ",
+ "耍": "shuǎ",
+ "聊": "liáo",
+ "膛": "táng",
+ "茂": "mào",
+ "華": "huá",
+ "賤": "jiàn",
+ "贱": "jiàn",
+ "鏟": "chǎn",
+ "铲": "chǎn",
+ "倦": "juàn",
+ "卡": "kǎ",
+ "卸": "xiè",
+ "嘲": "cháo",
+ "囪": "cōng",
+ "囱": "cōng",
+ "圣": "shèng",
+ "垄": "lǒng",
+ "垒": "lěi",
+ "壕": "háo",
+ "壘": "lěi",
+ "壟": "lǒng",
+ "寡": "guǎ",
+ "巢": "cháo",
+ "氛": "fēn",
+ "沥": "lì",
+ "瀝": "lì",
+ "炯": "jiǒng",
+ "甸": "diàn",
+ "聖": "shèng",
+ "荔": "lì",
+ "葫": "hú",
+ "賴": "lài",
+ "赖": "lài",
+ "蹦": "bèng",
+ "鑼": "luó",
+ "锣": "luó",
+ "鵲": "què",
+ "鹊": "què",
+ "乃": "nǎi",
+ "忠": "zhōng",
+ "恼": "nǎo",
+ "惱": "nǎo",
+ "拜": "bài",
+ "搀": "chān",
+ "攙": "chān",
+ "旺": "wàng",
+ "昧": "mèi",
+ "滋": "zī",
+ "磷": "lín",
+ "竭": "jié",
+ "絡": "luò",
+ "絨": "róng",
+ "绒": "róng",
+ "络": "luò",
+ "署": "shǔ",
+ "脯": "pú",
+ "覆": "fù",
+ "訟": "sòng",
+ "讼": "sòng",
+ "轎": "jiào",
+ "轿": "jiào",
+ "霸": "bà",
+ "馱": "tuó",
+ "驮": "tuó",
+ "亚": "yà",
+ "亞": "yà",
+ "劣": "liè",
+ "咙": "lóng",
+ "喃": "nán",
+ "嚨": "lóng",
+ "奏": "zòu",
+ "奠": "diàn",
+ "嫩": "nèn",
+ "尔": "ěr",
+ "徐": "xú",
+ "捡": "jiǎn",
+ "搏": "bó",
+ "撿": "jiǎn",
+ "枉": "wǎng",
+ "煌": "huáng",
+ "爾": "ěr",
+ "猩": "xīng",
+ "盔": "kuī",
+ "窟": "kū",
+ "窿": "lóng",
+ "粘": "zhān",
+ "紐": "niǔ",
+ "纽": "niǔ",
+ "罚": "fá",
+ "罰": "fá",
+ "衫": "shān",
+ "謙": "qiān",
+ "謬": "miù",
+ "谦": "qiān",
+ "谬": "miù",
+ "郊": "jiāo",
+ "頃": "qǐng",
+ "顷": "qǐng",
+ "駁": "bó",
+ "驳": "bó",
+ "储": "chǔ",
+ "僱": "gù",
+ "儲": "chǔ",
+ "剿": "jiǎo",
+ "卜": "bo",
+ "叭": "bā",
+ "喇": "lǎ",
+ "奉": "fèng",
+ "弥": "mí",
+ "彌": "mí",
+ "揍": "zòu",
+ "搂": "lǒu",
+ "摟": "lǒu",
+ "旬": "xún",
+ "杰": "jié",
+ "毅": "yì",
+ "氓": "máng",
+ "沸": "fèi",
+ "涤": "dí",
+ "滌": "dí",
+ "灶": "zào",
+ "磺": "huáng",
+ "萝": "luó",
+ "蘿": "luó",
+ "融": "róng",
+ "誓": "shì",
+ "賺": "zhuàn",
+ "赚": "zhuàn",
+ "辣": "là",
+ "鈔": "chāo",
+ "錫": "xī",
+ "钞": "chāo",
+ "锡": "xī",
+ "闡": "chǎn",
+ "阐": "chǎn",
+ "雇": "gù",
+ "乳": "rǔ",
+ "伺": "cì",
+ "剖": "pōu",
+ "吟": "yín",
+ "址": "zhǐ",
+ "坝": "bà",
+ "坯": "pī",
+ "垫": "diàn",
+ "堡": "bǎo",
+ "墊": "diàn",
+ "壩": "bà",
+ "怦": "pēng",
+ "恍": "huǎng",
+ "惭": "cán",
+ "慕": "mù",
+ "慚": "cán",
+ "拐": "guǎi",
+ "砌": "qì",
+ "綢": "chóu",
+ "绸": "chóu",
+ "羡": "xiàn",
+ "羨": "xiàn",
+ "肿": "zhǒng",
+ "腫": "zhǒng",
+ "腹": "fù",
+ "蓬": "péng",
+ "詢": "xún",
+ "諸": "zhū",
+ "询": "xún",
+ "诸": "zhū",
+ "賠": "péi",
+ "赔": "péi",
+ "踌": "chóu",
+ "躇": "chú",
+ "躊": "chóu",
+ "逻": "luó",
+ "邏": "luó",
+ "陈": "chén",
+ "陳": "chén",
+ "鵝": "é",
+ "鹅": "é",
+ "匾": "biǎn",
+ "墓": "mù",
+ "忧": "yōu",
+ "悅": "yuè",
+ "悦": "yuè",
+ "惑": "huò",
+ "憂": "yōu",
+ "捣": "dǎo",
+ "搓": "cuō",
+ "搗": "dǎo",
+ "档": "dàng",
+ "檔": "dàng",
+ "歉": "qiàn",
+ "泌": "mì",
+ "溅": "jiàn",
+ "澡": "zǎo",
+ "濺": "jiàn",
+ "磕": "kē",
+ "稅": "shuì",
+ "税": "shuì",
+ "篷": "péng",
+ "翼": "yì",
+ "蟀": "shuài",
+ "蟋": "xī",
+ "辞": "cí",
+ "辭": "cí",
+ "遙": "yáo",
+ "遥": "yáo",
+ "陶": "táo",
+ "饒": "ráo",
+ "饶": "ráo",
+ "丧": "sàng",
+ "俯": "fǔ",
+ "呗": "bei",
+ "唄": "bei",
+ "喪": "sàng",
+ "奈": "nài",
+ "宁": "níng",
+ "寧": "níng",
+ "帆": "fān",
+ "廓": "kuò",
+ "拟": "nǐ",
+ "捂": "wǔ",
+ "擬": "nǐ",
+ "氨": "ān",
+ "汞": "gǒng",
+ "淌": "tǎng",
+ "炒": "chǎo",
+ "煎": "jiān",
+ "繡": "xiù",
+ "绣": "xiù",
+ "艇": "tǐng",
+ "躬": "gōng",
+ "辱": "rǔ",
+ "酬": "chóu",
+ "醋": "cù",
+ "鋤": "chú",
+ "鏽": "xiù",
+ "锄": "chú",
+ "锈": "xiù",
+ "閱": "yuè",
+ "阅": "yuè",
+ "隧": "suì",
+ "雌": "cí",
+ "鞠": "jū",
+ "骼": "gé",
+ "佳": "jiā",
+ "冊": "cè",
+ "册": "cè",
+ "啸": "xiào",
+ "嘯": "xiào",
+ "姻": "yīn",
+ "孵": "fū",
+ "憾": "hàn",
+ "扳": "bān",
+ "敷": "fū",
+ "棋": "qí",
+ "涛": "tāo",
+ "濤": "tāo",
+ "熔": "róng",
+ "熬": "áo",
+ "狮": "shī",
+ "獅": "shī",
+ "畔": "pàn",
+ "疏": "shū",
+ "納": "nà",
+ "纳": "nà",
+ "腈": "jīng",
+ "膏": "gāo",
+ "舰": "jiàn",
+ "艦": "jiàn",
+ "蜓": "tíng",
+ "蜻": "qīng",
+ "謊": "huǎng",
+ "谎": "huǎng",
+ "趋": "qū",
+ "趨": "qū",
+ "軋": "yà",
+ "轧": "yà",
+ "頒": "bān",
+ "颁": "bān",
+ "騾": "luó",
+ "骡": "luó",
+ "鵪": "ān",
+ "鶉": "chún",
+ "鹌": "ān",
+ "鹑": "chún",
+ "侍": "shì",
+ "壽": "shòu",
+ "奸": "jiān",
+ "寿": "shòu",
+ "慈": "cí",
+ "捷": "jié",
+ "枣": "zǎo",
+ "棗": "zǎo",
+ "榜": "bǎng",
+ "毡": "zhān",
+ "氈": "zhān",
+ "汪": "wāng",
+ "津": "jīn",
+ "滔": "tāo",
+ "狡": "jiǎo",
+ "猾": "huá",
+ "申": "shēn",
+ "畏": "wèi",
+ "祥": "xiáng",
+ "穗": "suì",
+ "簇": "cù",
+ "翁": "wēng",
+ "茸": "róng",
+ "蚩": "chī",
+ "蠕": "rú",
+ "衙": "yá",
+ "謹": "jǐn",
+ "谨": "jǐn",
+ "閘": "zhá",
+ "闸": "zhá",
+ "驟": "zhòu",
+ "骤": "zhòu",
+ "乖": "guāi",
+ "咆": "páo",
+ "哮": "xiào",
+ "孙": "sūn",
+ "孫": "sūn",
+ "寇": "kòu",
+ "崩": "bēng",
+ "庞": "páng",
+ "憋": "biē",
+ "捎": "shāo",
+ "敞": "chǎng",
+ "晕": "yūn",
+ "暈": "yūn",
+ "柏": "bǎi",
+ "瀑": "pù",
+ "烘": "hōng",
+ "熏": "xūn",
+ "舀": "yǎo",
+ "荐": "jiàn",
+ "蔼": "ǎi",
+ "藹": "ǎi",
+ "衰": "shuāi",
+ "謠": "yáo",
+ "譏": "jī",
+ "讥": "jī",
+ "谣": "yáo",
+ "跺": "duò",
+ "逛": "guàng",
+ "鷹": "yīng",
+ "鹰": "yīng",
+ "龐": "páng",
+ "俱": "jù",
+ "厢": "xiāng",
+ "叙": "xù",
+ "咂": "zā",
+ "屹": "yì",
+ "廂": "xiāng",
+ "怯": "qiè",
+ "拘": "jū",
+ "携": "xié",
+ "摩": "mó",
+ "攜": "xié",
+ "敘": "xù",
+ "暢": "chàng",
+ "梗": "gěng",
+ "沃": "wò",
+ "滥": "làn",
+ "濫": "làn",
+ "狐": "hú",
+ "狸": "lí",
+ "琢": "zuó",
+ "畅": "chàng",
+ "眶": "kuàng",
+ "簸": "bǒ",
+ "粜": "tiào",
+ "糕": "gāo",
+ "糶": "tiào",
+ "絹": "juàn",
+ "綿": "mián",
+ "縷": "lǚ",
+ "绢": "juàn",
+ "绵": "mián",
+ "缕": "lǚ",
+ "菩": "pú",
+ "萤": "yíng",
+ "萨": "sà",
+ "薩": "sà",
+ "螢": "yíng",
+ "鍍": "dù",
+ "镀": "dù",
+ "劈": "pī",
+ "厦": "shà",
+ "咋": "zǎ",
+ "啃": "kěn",
+ "屉": "tì",
+ "屜": "tì",
+ "嵌": "qiàn",
+ "廈": "shà",
+ "徊": "huái",
+ "徘": "pái",
+ "捍": "hàn",
+ "撼": "hàn",
+ "斃": "bì",
+ "杉": "shān",
+ "毙": "bì",
+ "泳": "yǒng",
+ "浆": "jiāng",
+ "湾": "wān",
+ "漾": "yàng",
+ "漿": "jiāng",
+ "灣": "wān",
+ "煞": "shā",
+ "疙": "gē",
+ "瘩": "da",
+ "碌": "lù",
+ "磅": "bàng",
+ "粹": "cuì",
+ "繳": "jiǎo",
+ "缰": "jiāng",
+ "缴": "jiǎo",
+ "舶": "bó",
+ "茅": "máo",
+ "薪": "xīn",
+ "裕": "yù",
+ "鉗": "qián",
+ "鑲": "xiāng",
+ "钳": "qián",
+ "镶": "xiāng",
+ "韁": "jiāng",
+ "丁": "dīng",
+ "偎": "wēi",
+ "凄": "qī",
+ "凤": "fèng",
+ "凰": "huáng",
+ "叼": "diāo",
+ "姆": "mǔ",
+ "尿": "niào",
+ "弦": "xián",
+ "惕": "tì",
+ "惧": "jù",
+ "懼": "jù",
+ "挎": "kuà",
+ "撅": "juē",
+ "杜": "dù",
+ "桨": "jiǎng",
+ "槳": "jiǎng",
+ "樟": "zhāng",
+ "欧": "ōu",
+ "歐": "ōu",
+ "淒": "qī",
+ "淳": "chún",
+ "渺": "miǎo",
+ "珊": "shān",
+ "瑚": "hú",
+ "痒": "yǎng",
+ "瞥": "piē",
+ "砰": "pēng",
+ "硝": "xiāo",
+ "祸": "huò",
+ "禍": "huò",
+ "稚": "zhì",
+ "糙": "cāo",
+ "紳": "shēn",
+ "绅": "shēn",
+ "羽": "yǔ",
+ "舔": "tiǎn",
+ "葵": "kuí",
+ "蚓": "yǐn",
+ "蚯": "qiū",
+ "豺": "chái",
+ "郑": "zhèng",
+ "鄭": "zhèng",
+ "鐺": "dāng",
+ "铛": "dāng",
+ "鳳": "fèng",
+ "鵑": "juān",
+ "鹃": "juān",
+ "伶": "líng",
+ "咀": "jǔ",
+ "噪": "zào",
+ "嚼": "jué",
+ "娛": "yú",
+ "娱": "yú",
+ "屠": "tú",
+ "怔": "zhēng",
+ "惩": "chéng",
+ "懲": "chéng",
+ "捐": "juān",
+ "捶": "chuí",
+ "撩": "liāo",
+ "枚": "méi",
+ "枢": "shū",
+ "槛": "kǎn",
+ "樞": "shū",
+ "檻": "kǎn",
+ "漩": "xuán",
+ "碟": "dié",
+ "秤": "chèng",
+ "竽": "yú",
+ "笆": "bā",
+ "篱": "lí",
+ "籬": "lí",
+ "臣": "chén",
+ "茎": "jīng",
+ "莖": "jīng",
+ "蚜": "yá",
+ "蝙": "biān",
+ "蝠": "fú",
+ "褂": "guà",
+ "詭": "guǐ",
+ "诡": "guǐ",
+ "豹": "bào",
+ "賀": "hè",
+ "贺": "hè",
+ "蹄": "tí",
+ "鈾": "yóu",
+ "錘": "chuí",
+ "鍬": "qiāo",
+ "铀": "yóu",
+ "锤": "chuí",
+ "锹": "qiāo",
+ "雹": "báo",
+ "霞": "xiá",
+ "呃": "è",
+ "咕": "gū",
+ "哑": "yǎ",
+ "哺": "bǔ",
+ "唬": "hǔ",
+ "唾": "tuò",
+ "啞": "yǎ",
+ "嗅": "xiù",
+ "嗐": "hài",
+ "嘀": "dí",
+ "嘶": "sī",
+ "尸": "shī",
+ "屎": "shǐ",
+ "悠": "yōu",
+ "惋": "wǎn",
+ "愕": "è",
+ "抿": "mǐn",
+ "掷": "zhì",
+ "搔": "sāo",
+ "搪": "táng",
+ "擲": "zhì",
+ "旷": "kuàng",
+ "曠": "kuàng",
+ "桅": "wéi",
+ "梨": "lí",
+ "榕": "róng",
+ "泣": "qì",
+ "泵": "bèng",
+ "涕": "tì",
+ "猬": "wèi",
+ "瑩": "yíng",
+ "疆": "jiāng",
+ "矗": "chù",
+ "硅": "guī",
+ "綴": "zhuì",
+ "繭": "jiǎn",
+ "缀": "zhuì",
+ "腮": "sāi",
+ "芯": "xīn",
+ "茧": "jiǎn",
+ "莹": "yíng",
+ "蕉": "jiāo",
+ "蕴": "yùn",
+ "藤": "téng",
+ "蘊": "yùn",
+ "蝟": "wèi",
+ "誣": "wū",
+ "誦": "sòng",
+ "諷": "fěng",
+ "讽": "fěng",
+ "诬": "wū",
+ "诵": "sòng",
+ "販": "fàn",
+ "贩": "fàn",
+ "蹭": "cèng",
+ "鈷": "gǔ",
+ "鋸": "jù",
+ "鎂": "měi",
+ "鐮": "lián",
+ "钴": "gǔ",
+ "锯": "jù",
+ "镁": "měi",
+ "镰": "lián",
+ "隘": "ài",
+ "髦": "máo",
+ "鱷": "è",
+ "鳄": "è",
+ "鸝": "lí",
+ "鹂": "lí",
+ "丸": "wán",
+ "伊": "yī",
+ "勒": "lēi",
+ "勘": "kān",
+ "匙": "shi",
+ "卑": "bēi",
+ "吉": "jí"
+ }
+}
diff --git a/assets/template.source.html b/assets/template.source.html
new file mode 100644
index 00000000..d9e2b74f
--- /dev/null
+++ b/assets/template.source.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+Distilly · 个人画像
+
+
+
+跳到正文 / Skip to content
+
+
+
+
+
+
+
+
+
+
diff --git a/bin/distilly.mjs b/bin/distilly.mjs
index 7460a406..f5330ab8 100755
--- a/bin/distilly.mjs
+++ b/bin/distilly.mjs
@@ -1,204 +1,226 @@
#!/usr/bin/env node
-
-import {
- cpSync,
- existsSync,
- mkdirSync,
- readFileSync,
- renameSync,
- rmSync,
-} from "node:fs";
-import { homedir } from "node:os";
-import { basename, dirname, join, parse, resolve } from "node:path";
+/**
+ * Distilly entry point.
+ *
+ * Contract: `docs/v2/CONTRACT.md` §1 — this is the only user-facing entry. It
+ * parses global flags, resolves a subcommand through the registry in
+ * `src/commands/index.mjs`, prints a bilingual help screen, and always answers
+ * with the receipt shape from §3 when `--json` is set.
+ *
+ * Adding a command: create `src/commands/.mjs`, call `register(...)` from
+ * it, and import that module below. See `docs/v2/NODE-CORE.md`.
+ */
+
+import { existsSync, readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-const packageRoot = fileURLToPath(new URL("..", import.meta.url));
-const packageMetadata = JSON.parse(
- readFileSync(join(packageRoot, "package.json"), "utf8"),
-);
-
-const payloadEntries = [
+import { ArgError, wantsHelp } from "../src/cli/args.mjs";
+import { isEntryPoint } from "../src/cli/entry.mjs";
+import { CliError, createReceipt, createReporter } from "../src/cli/receipt.mjs";
+// Importing the registry also registers every built-in command module.
+import {
+ lookup,
+ missingCommandError,
+ renderCommandHelp,
+ renderHelp,
+ resolveCommand,
+} from "../src/commands/index.mjs";
+
+export const packageRoot = fileURLToPath(new URL("..", import.meta.url));
+const packageMetadata = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
+const version = packageMetadata.version;
+const binary = "distilly";
+
+/**
+ * Every path the published package must contain. `src/` and `assets/` are not
+ * optional: this file imports `../src/cli/args.mjs` at startup and the viewer
+ * template is read from `assets/`. Kept in sync with `package.json`'s `files`
+ * by `validatePayload`, which refuses to pack when they disagree.
+ */
+export const payloadEntries = [
"SKILL.md",
"prompts",
"references",
- "tools",
- "requirements.txt",
+ "bin",
+ "src",
+ "assets",
+ "scripts",
+ "package.json",
"INSTALL.md",
"INSTALL_EN.md",
"LICENSE",
"CITATION.cff",
];
-const hosts = {
- "claude-code": () => join(homedir(), ".claude", "skills", "distilly"),
- openclaw: () =>
- join(homedir(), ".openclaw", "workspace", "skills", "distilly"),
- hermes: () =>
- join(homedir(), ".hermes", "skills", "openclaw-imports", "distilly"),
- codex: () => join(homedir(), ".agents", "skills", "distilly"),
- "deepseek-harness": () =>
- join(process.env.DSH_HOME || join(homedir(), ".dsh"), "skills", "distilly"),
- pi: () => join(homedir(), ".pi", "agent", "skills", "distilly"),
- "grok-build": () => join(homedir(), ".grok", "skills", "distilly"),
- opencode: () =>
- join(homedir(), ".config", "opencode", "skills", "distilly"),
-};
-
-const aliases = {
- claude: "claude-code",
- deepseek: "deepseek-harness",
- grok: "grok-build",
-};
-
-function printHelp() {
- console.log(`Distilly ${packageMetadata.version}
-
-Install the Distilly creator Skill into a supported agent host.
-
-Usage:
- distilly install [--force]
- distilly install --path [--force]
-
-Hosts:
- claude-code, openclaw, hermes, codex, deepseek-harness,
- pi, grok-build, opencode
-
-Options:
- --force Preserve an existing install as a timestamped backup, then install
- --path Install to a custom path whose final directory is named distilly
- --version Print the package version
- --help Show this help
-`);
-}
-
-function fail(message) {
- console.error(`Error: ${message}`);
- process.exit(1);
+/** npm always includes these whatever `files` says, so they need no pattern. */
+const ALWAYS_PACKED = new Set(["package.json", "README.md", "LICENSE", "LICENCE"]);
+
+/**
+ * Does at least one `files` pattern put `entry` into the tarball?
+ *
+ * npm's `files` accepts bare names (`SKILL.md`), directories (`src/`) and globs
+ * (`prompts/**`); a directory pattern covers everything below it. Only the
+ * shapes this manifest actually uses are supported — an unrecognised pattern is
+ * reported as "does not cover", never silently treated as a match.
+ */
+function packs(entry, patterns) {
+ if (ALWAYS_PACKED.has(entry)) return true;
+ return patterns.some((raw) => {
+ const pattern = String(raw).replace(/\/+$/, "");
+ if (pattern === entry) return true;
+ if (entry.startsWith(`${pattern}/`)) return true;
+ if (!pattern.includes("*")) return false;
+ const source = pattern
+ .split("**")
+ .map((part) =>
+ part
+ .split("*")
+ .map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
+ .join("[^/]*"),
+ )
+ .join(".*");
+ return new RegExp(`^${source}$`).test(entry);
+ });
}
-function validatePayload() {
- const missing = payloadEntries.filter(
- (entry) => !existsSync(join(packageRoot, entry)),
- );
+/**
+ * Prepack guard: the published payload must be complete and version-consistent.
+ *
+ * This runs from `prepack`, so it must judge the tarball npm is about to build,
+ * not the working tree. Checking only `root` is what let a broken package ship:
+ * `files` still listed the Python-era `tools/` and `requirements.txt` and had
+ * dropped `src/` and `assets/`, `--check-package` printed "payload is valid"
+ * because the repo tree had everything, and the extracted tarball could not even
+ * start. So the manifest is checked too.
+ */
+export function validatePayload(root = packageRoot) {
+ const missing = payloadEntries.filter((entry) => !existsSync(join(root, entry)));
if (missing.length > 0) {
- fail(`package payload is missing: ${missing.join(", ")}`);
+ throw new CliError(`package payload is missing: ${missing.join(", ")}`, {
+ code: "payload-incomplete",
+ remedy: "restore the missing paths or update payloadEntries in bin/distilly.mjs.",
+ });
}
- const skill = readFileSync(join(packageRoot, "SKILL.md"), "utf8");
- if (!skill.includes(`version: "${packageMetadata.version}"`)) {
- fail("package.json version does not match SKILL.md");
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
+ const patterns = Array.isArray(manifest.files) ? manifest.files : [];
+ if (patterns.length === 0) {
+ throw new CliError("package.json declares no `files`, so the tarball would be unpredictable", {
+ code: "payload-manifest",
+ remedy: "add a `files` array listing bin/, src/, assets/, scripts/ and the documents.",
+ });
}
-}
-function expandHome(inputPath) {
- if (inputPath === "~") return homedir();
- if (inputPath.startsWith("~/")) return join(homedir(), inputPath.slice(2));
- return inputPath;
-}
-
-function validateTarget(inputPath) {
- const target = resolve(expandHome(inputPath));
- const parsed = parse(target);
- if (target === parsed.root || target === resolve(homedir())) {
- fail("refusing to install into a filesystem root or home directory");
- }
- if (basename(target) !== "distilly") {
- fail("the install path must end with a directory named distilly");
+ const absent = patterns
+ .map((raw) => String(raw).replace(/\/+$/, ""))
+ .filter((pattern) => !existsSync(join(root, pattern)));
+ if (absent.length > 0) {
+ throw new CliError(`package.json \`files\` names paths that do not exist: ${absent.join(", ")}`, {
+ code: "payload-manifest",
+ remedy: "remove the stale entries (or restore the paths) so the manifest describes this tree.",
+ });
}
- return target;
-}
-function parseInstallArgs(args) {
- let host;
- let customPath;
- let force = false;
-
- for (let index = 0; index < args.length; index += 1) {
- const arg = args[index];
- if (arg === "--force") {
- force = true;
- } else if (arg === "--path") {
- customPath = args[index + 1];
- if (!customPath) fail("--path requires a value");
- index += 1;
- } else if (arg.startsWith("--")) {
- fail(`unknown option: ${arg}`);
- } else if (!host) {
- host = aliases[arg] || arg;
- } else {
- fail(`unexpected argument: ${arg}`);
- }
+ const omitted = payloadEntries.filter((entry) => !packs(entry, patterns));
+ if (omitted.length > 0) {
+ throw new CliError(`package.json \`files\` would omit required paths: ${omitted.join(", ")}`, {
+ code: "payload-incomplete",
+ remedy: `add ${omitted.map((entry) => `"${entry}/"`).join(", ")} to \`files\`; the package cannot run without them.`,
+ });
}
- if (customPath) return { target: validateTarget(customPath), force };
- if (!host) fail("choose a host or pass --path");
- if (!hosts[host]) fail(`unsupported host: ${host}`);
- return { target: validateTarget(hosts[host]()), force };
+ const skill = readFileSync(join(root, "SKILL.md"), "utf8");
+ if (!skill.includes(`version: "${version}"`)) {
+ throw new CliError("package.json version does not match SKILL.md", {
+ code: "version-mismatch",
+ remedy: `set SKILL.md frontmatter version to "${version}" (or bump package.json).`,
+ });
+ }
}
-function timestamp() {
- return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
+function failureReceipt(command, error) {
+ return createReceipt(command, {
+ ok: false,
+ error: {
+ code: error.code ?? "error",
+ message: error.message,
+ ...(error.remedy ? { remedy: error.remedy } : {}),
+ },
+ warnings: [error.message],
+ });
}
-function install(target, force) {
- validatePayload();
+async function main(argv) {
+ // `--json` is global (CONTRACT §1): every subcommand answers with a receipt.
+ const json = argv.includes("--json");
+ const args = argv.filter((arg) => arg !== "--json");
+ const reporter = createReporter(json);
+
+ if (args.includes("--check-package")) {
+ validatePayload();
+ // A validation diagnostic, not command output: `prepack` shares stdout with
+ // `npm pack --json`, which must stay parseable.
+ process.stderr.write("Distilly package payload is valid.\n");
+ return 0;
+ }
- if (existsSync(target) && !force) {
- fail(`${target} already exists; rerun with --force to preserve and replace it`);
+ // Global flags only count before a command name: `skill version rollback
+ // --version v1` must reach the version manager, not print the CLI version.
+ if (args[0] === "--version") {
+ reporter.line(version);
+ return 0;
}
- const parent = dirname(target);
- const staging = join(parent, `.distilly-install-${process.pid}`);
- let backup;
+ const { name, rest } = resolveCommand(args);
+ const command = lookup(name);
- mkdirSync(parent, { recursive: true });
- if (existsSync(staging)) {
- fail(`temporary install path already exists: ${staging}`);
+ if (args.length === 0 || args[0] === "help" || (wantsHelp(args) && !command)) {
+ process.stdout.write(renderHelp({ version, binary }));
+ return 0;
}
- try {
- mkdirSync(staging);
- for (const entry of payloadEntries) {
- cpSync(join(packageRoot, entry), join(staging, entry), {
- recursive: true,
- preserveTimestamps: true,
- });
- }
-
- if (!existsSync(join(staging, "SKILL.md"))) {
- throw new Error("staged install does not contain SKILL.md");
- }
-
- if (existsSync(target)) {
- backup = `${target}.backup-${timestamp()}`;
- renameSync(target, backup);
- }
- renameSync(staging, target);
- } catch (error) {
- if (existsSync(staging)) {
- rmSync(staging, { recursive: true, force: true });
- }
- if (backup && !existsSync(target) && existsSync(backup)) {
- renameSync(backup, target);
- }
- throw error;
+ if (command === null) {
+ throw missingCommandError(name);
}
- console.log(`Distilly ${packageMetadata.version} installed at ${target}`);
- if (backup) console.log(`Previous install preserved at ${backup}`);
+ if (wantsHelp(args)) {
+ process.stdout.write(`${renderCommandHelp(command, { binary })}\n`);
+ return 0;
+ }
+
+ const result = (await command.run({
+ argv: rest,
+ json,
+ reporter,
+ ctx: { packageRoot, version, binary },
+ })) ?? {};
+
+ const receipt = result.receipt ?? createReceipt(command.name);
+ reporter.finish(receipt);
+ if (result.exitCode !== undefined) return result.exitCode;
+ return receipt.ok === false ? 1 : 0;
}
-const args = process.argv.slice(2);
-if (args.includes("--check-package")) {
- validatePayload();
- console.log("Distilly package payload is valid.");
-} else if (args.includes("--version")) {
- console.log(packageMetadata.version);
-} else if (args.length === 0 || args.includes("--help") || args[0] === "help") {
- printHelp();
-} else if (args[0] === "install") {
- const { target, force } = parseInstallArgs(args.slice(1));
- install(target, force);
-} else {
- fail(`unknown command: ${args[0]}`);
+// Dispatch only when this file is the process entry point. Importing it (tests do,
+// to reach `validatePayload` and `payloadEntries`) must not run a command with the
+// importer's argv. `isEntryPoint` resolves symlinks, so this still fires when the
+// CLI is reached through an npm `bin` shim or any other symlinked path.
+if (isEntryPoint(import.meta.url)) {
+ try {
+ process.exitCode = await main(process.argv.slice(2));
+ } catch (error) {
+ const json = process.argv.includes("--json");
+ const command = process.argv.slice(2).find((arg) => !arg.startsWith("-")) ?? null;
+ const reporter = createReporter(json);
+ const failure =
+ error instanceof CliError || error instanceof ArgError
+ ? error
+ : new CliError(error?.message ?? String(error), { code: "unexpected" });
+
+ reporter.warn(`Error: ${failure.message}`);
+ if (failure.remedy) reporter.warn(`Remedy: ${failure.remedy}`);
+ reporter.finish(failureReceipt(command, failure));
+ process.exitCode = failure.exitCode ?? 1;
+ }
}
diff --git a/docs/NUWA_CONTRAST.md b/docs/NUWA_CONTRAST.md
new file mode 100644
index 00000000..dde160b5
--- /dev/null
+++ b/docs/NUWA_CONTRAST.md
@@ -0,0 +1,149 @@
+# 对照:nuwa-skill 怎么做的,我们该抄什么
+
+> 起因:讨论「语料形状」与「效果怎么量」时,被要求去看 `alchaincyf/nuwa-skill` 的做法。
+> 本文只记录**跑过、读过**的事实,不转述宣传语。抓取日期 2026-09-15,仓库 `main` 分支。
+
+## 0. 它是什么
+
+- 形态:**纯 Skill**(`SKILL.md` + `references/` + `scripts/` + `examples/`),MIT,靠 Agent Skills 协议跑在
+ Claude Code / Codex / Cursor / OpenClaw / Hermes 等 runtime。**没有 CLI、没有依赖、没有机械门禁。**
+- 定位:输入一个名字 → 自动调研 → 提炼这个人的**思维框架**(不是角色扮演),产出一个 `*-perspective` Skill。
+- 它的 README 开头写明师承:[同事.skill](https://github.com/titanwings/colleague-skill)「证明了蒸馏一个人是可行的」——
+ 也就是我们的前身。它把问题从「蒸馏同事」推到「蒸馏任何公众人物」。
+
+## 1. 它的流程(6 个 Phase,带两个人工检查点)
+
+| Phase | 做什么 | 关键设计 |
+|---|---|---|
+| 0 分流 | 明确人名 → 直接路径;模糊需求 → 诊断路径(10 个需求维度反推人选) | 没想好蒸馏谁时,先做需求定位再推荐 |
+| 0A 澄清 | 确认人名/聚焦方向/用途/新建或更新,然后问一句:**「你手上有没有这个人的一手素材?书籍 PDF、演讲/访谈 transcript、视频字幕、博客导出」** | 这一问决定采集模式 |
+| 0.5 建目录 | 先建 `references/research/01..06` + `sources/` | 调研必须落盘;「不存文件的调研等于没做」 |
+| 1 采集 | **6 个并行 subagent**:著作 / 对话 / 表达 / 他者批评 / 决策 / 时间线,各写一个 md,标注一手 vs 二手,矛盾保留 | 三种模式:纯网络搜索 / 本地语料优先 / 纯本地语料;本地优先时只对缺失维度补搜 |
+| 1.5 检查点 | 展示来源数量表 + 矛盾点 + 信息不足维度,**等用户确认** | 「垃圾进垃圾出,在这里拦截比在 Phase 4 返工便宜」 |
+| 2 提炼 | 心智模型 3-7 个 + 决策启发式 5-10 条 + 表达 DNA + 价值观与反模式 + 内在张力 + 诚实边界 | 见 §2 的三重验证 |
+| 2.5 检查点 | 展示提炼摘要,**等用户确认** | 「提炼是主观判断最重的环节」 |
+| 3 构建 | 按模板填 `SKILL.md`,并生成「回答工作流(Agentic Protocol)」 | 从心智模型**反推**研究维度,遇到需要事实的问题强制先联网核查 |
+| 4 验证 | 子 agent 跑 3 道已知立场题 + 1 道超范围题 + 1 道风格题 | 独立于主 agent,避免自评偏差 |
+| 5 精炼 | 双 agent(结构评估 + 触发条件评审)产出改进建议 | Phase 2→4 最多循环 2 次,不无限打磨 |
+
+## 2. 它凭什么说「这是心智模型」——三重验证
+
+一个观点要被收录为心智模型,必须同时通过:
+
+1. **跨域复现**:在此人讨论的 ≥2 个不同领域出现;
+2. **有生成力**:能用它推断此人对新问题的立场;
+3. **有排他性**:不是所有聪明人都会这样想。
+
+只过 1 重 → **降级**为「决策启发式」;0 重 → 丢弃。
+表达层另有量化口径:平均句长、疑问句比例、类比密度、第一人称使用率、确定性语气比例、转折频率。
+
+## 3. 它怎么处理「说话人」——不处理,绕开
+
+这是本次对照对我们最要紧的一条。
+
+它的字幕工具链只有两个脚本(`scripts/download_subtitles.sh` 55 行、`scripts/srt_to_transcript.py` 108 行,
+sha256 见 §7)。清洗逻辑是:**去掉序号行、时间戳行、HTML 标签,相邻重复行去重,短句合并成段**。
+说话人标签**原样留在正文里**,交给读文本的模型自己理解。
+
+它能这么干,是因为它的语料形状天然是**一个人的一手产出**(著作、本人演讲/访谈、本人社媒)。
+多人会议根本不是它的输入。**它不是解决了说话人归属问题,是产品设计上绕开了它。**
+
+用我们的公开语料实跑它的脚本(`us-house-floor-2009-07-29/transcript.srt`,C-SPAN 真实字幕):
+
+```
+$ python3 srt_to_transcript.py us-house.srt us-house.nuwa.txt
+✅ 转换完成 字数: 36399 段落数: 825
+
+原始 SRT : 84860 字符, 5430 行
+它的 transcript : 36399 字符, 825 段
+说话人标签 : 84 处 → 产物里作为普通文本保留 84 处(无结构)
+产物里还有时间戳吗 : 否(时间戳去干净了)
+```
+
+而它的产物开头是这样的:
+
+```
+starttime 1248896221.592 QUORUM IS NOT PRESENT AND MAKES WITH THE UNITED STATES HOUSE OF REPRESENTATIVES.
+...
+>> MR. S SUSPEND THE RULES AND PASS THE , A BILL TO RESTORE THE HIGHWAY TRUST FUND ...
+```
+
+两处泄漏值得记下来:**字幕头字段 `starttime 1248896221.592` 被当成正文写进了语料**;
+**`>>` 换人标记没被剥掉**。我们的 `parse` 层对同一份文件会把这些头字段收进 warnings 并在产物里
+报出来,而不是混进正文。我们这边同一份语料的产物是 `knowledge/text/*.md`:`[k00NN] <时间> <说话人>:文本`,
+每行可回指到原文的字节区间,说话人从 84 处标签里认出 82 条记录。
+
+**结论**:它的路线要的是「喂给模型读的干净语料」,我们要的是「每条结论能回指的审计链」。
+两者的取舍不同,但「一个人」这个输入形状是对的,我们此前在会议记录上优化解析器是错的。
+
+## 4. 它怎么量效果——保真度评分卡
+
+`references/fidelity-scorecard.md`,100 分五维:
+
+| 维度 | 分 | 测法 |
+|---|---|---|
+| 立场一致性 | 30 | 3 道人物公开表态过的问题,对比回答方向 |
+| 风格辨识度 | 20 | 不看名字盲读,能否认出是谁(还是通用 AI 腔) |
+| 边缘诚实度 | 20 | 1 道超范围题:标注「这是推断」=满分,伪装成本人断言 = 0 |
+| 来源透明度 | 15 | **静态检查**:有来源章节、一手占比 >50%、关键引语有出处 |
+| 结构完整度 | 15 | **静态检查**:心智模型 3-7 个、诚实边界 ≥3 条、内在张力 ≥2 对、反模式清单 |
+
+等级 A ≥85 / B 70-84 / C 55-69 / D <55。铁律:**答题 agent 与评分 agent 必须是两个独立 agent,
+绝不自评自证**(其文档引用 SkillLens 论文称 LLM 自评准确率 46.4%、接近随机;该论文我们未独立核对)。
+反作弊四条:答题者不知道被测什么维度、评分者不参与答题、出题避开 skill 内已有示例对话、
+重要结论双评分 agent 且分差 >10 人工复核。15 个官方 Skill 已全部跑完并公开分数。
+
+**对我们最关键的判断**:这套评分卡的「立场一致性 30 分」需要**人物的公开已知立场**作为真值,
+因此**只适用于公众人物**。我们的 A/B holdout(按时间码切语料,A 给蒸馏者、B 只给裁判)
+不依赖真值,是给**私人语料**(同事 / 伴侣 / 自己)设计的。两者不是替代关系:
+**公开人物用评分卡,私人语料用 A/B holdout。**
+
+## 5. 逐项对照
+
+| 维度 | nuwa-skill | 我们(dot-skill / distilly) | 谁更强 |
+|---|---|---|---|
+| 输入形状 | 一个人的一手产出(著作/访谈/社媒),多源 | 通道采集(chat/邮件/文档/字幕)+ 锚点账本 | 形状它更对;覆盖面我们更广 |
+| 说话人 | 不处理,标签留成普通文本 | 解析层认领说话人(VTT voice / `Name:` / 全角冒号 / 句中换人),锚点带说话人 | 我们 |
+| 字幕清洗 | 108 行 Python,去时间戳/序号/标签/重复行 | 结构化解析 + 字节级锚点 + 头字段进 warnings | 我们(可回指、不泄漏) |
+| 提炼层 | 心智模型三重验证、决策启发式、表达 DNA、反模式、内在张力 | `derive/` 七个维度是**行为统计**(句长/标点/口头禅 n-gram/回应频次/时间线/边界/转向/冲突) | 它(认知层我们还没有) |
+| 效果测量 | 评分卡五维 + 独立双 agent + 公开 FIDELITY.md | `blind-test.mjs` A/B holdout(prepare/finalize/control/score) | 各有适用域;**它已经跑出数字,我们还没跑** |
+| 可验证性 | 「来源透明度」是静态检查(有没有章节、占比) | 锚点回指可机械验证到字节区间 | 我们 |
+| 工程门禁 | 无 | acceptance 12 项 / 目标审计 15 行 / visual-check 8 项 / prompt-lint / 模板防漂移 / CI | 我们 |
+| 分发 | 50+ runtime,一行 `npx skills add`,已产出 14 个成品 | 双入口(CLI + Skill),19 个 `ds/*` PR | 它更成熟;我们更可审计 |
+
+## 6. 可采纳项(按性价比排序,尚未动手)
+
+1. **换语料形状**(最优先、最便宜):选一个「有自己的访谈/演讲字幕」的人,而不是从会议里切人。
+ 这一步同时解锁评分卡(公开立场可查)。**与本仓库既有结论一致:单人语料的正确来源是单人产出。**
+2. **移植评分卡,并加一条它做不到的机械维度**:`scripts/fidelity.mjs` ——
+ 来源透明度 / 结构完整度 / **锚点回指率(机械可算)** 自动出分;
+ 立场一致性 / 风格辨识度 / 边缘诚实度 出 rubric + 独立裁判 prompt;产出 `FIDELITY.md`。
+ 两种模式并存:公众人物走评分卡,私人语料走 A/B holdout。
+3. **把三重验证做成 `derive` 的筛选门槛**:跨域复现 / 生成力 / 排他性。
+ 现状反证:`voice.catchphrases` 现在输出「二级缓」「级缓存」这类话题三元组——
+ 正是「没有排他性过滤」的典型症状。
+4. **抄信源纪律**:知乎 / 微信公众号 / 百度百科永远排除;中文只用权威媒体 + B站原始视频 + 小宇宙原始音频;
+ 「本地一手素材权重最高」;来源不足(<10 条)时提前降期望并把诚实边界写长。
+
+**不采纳**:把字幕洗成纯文本(我们要付锚点可回指的代价);去掉机械门禁(那是我们比它强的地方)。
+
+## 7. 来源与复现
+
+- 仓库:(MIT)
+- `SKILL.md`:
+ (同一 skill 的镜像,字段与 alchaincyf 版一致)
+- 方法论:`references/extraction-framework.md`、`references/fidelity-scorecard.md`
+- 成品样例:`examples/steve-jobs-perspective/SKILL.md`(27 KB,6 心智模型 / 8 决策启发式 / 五条诚实边界,
+ 含「幸存者偏差」自我批评)
+- 本次实跑的两个脚本(原样下载,仅存于 `/tmp`,未入库):
+ `srt_to_transcript.py` sha256 `fe9de1d39243920f1c28fee519faa45c18230c67e41d1c34d4af83cdbc4f6b8b`、
+ `download_subtitles.sh` sha256 `6b729f2117c552ca7a35013c0c7b2cac849db7057b7e5b6285d9a89c6ce93598`
+
+复现清洗对比:
+
+```bash
+curl -sSL -o /tmp/nuwa/srt_to_transcript.py \
+ https://raw.githubusercontent.com/alchaincyf/nuwa-skill/main/scripts/srt_to_transcript.py
+cp tests/fixtures/public-corpus/us-house-floor-2009-07-29/transcript.srt /tmp/nuwa/us-house.srt
+python3 /tmp/nuwa/srt_to_transcript.py /tmp/nuwa/us-house.srt /tmp/nuwa/us-house.nuwa.txt
+```
diff --git a/docs/PRD.md b/docs/PRD.md
index 2cab2d06..95f2ede4 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -1,5 +1,11 @@
# 同事.skill —— 产品需求文档 v2.0
+> ⚠️ **这份文件描述的是 v1(Python)时代的形状**:目录是 `colleagues/{slug}`、入口是
+> `tools/*.py`、实现是 Python。v2 已迁移到 Node 单栈:目录是 `skills//`、
+> 入口是 `bin/distilly.mjs`、性格层是 Layer 0–5、schema 是 v4。
+> **产品意图仍然有效,文件路径与实现细节请以 `docs/v2/CONTRACT.md` 与 `docs/v2/STATUS.md` 为准。**
+> 迁移对照表见 `SKILL.md` 与 `docs/v2/MIGRATION.md`。
+
---
## 一、产品概述
diff --git a/docs/SKILL_TYPE_ABSTRACTION_DESIGN.md b/docs/SKILL_TYPE_ABSTRACTION_DESIGN.md
index 0ec0c056..9924055b 100644
--- a/docs/SKILL_TYPE_ABSTRACTION_DESIGN.md
+++ b/docs/SKILL_TYPE_ABSTRACTION_DESIGN.md
@@ -1,5 +1,12 @@
# Skill Type Abstraction Design
+> ⚠️ **This document describes the v1 (Python-era) shape**: directories are
+> `colleagues/{slug}`, the entrypoint is `tools/*.py`, and the implementation is Python.
+> v2 is a single Node stack: `skills//`, entrypoint `bin/distilly.mjs`, a
+> Layer 0–5 persona, and schema v4. **The product intent still holds; for paths and
+> implementation details follow `docs/v2/CONTRACT.md` and `docs/v2/STATUS.md`.**
+> The migration table is in `SKILL.md` and `docs/v2/MIGRATION.md`.
+
Last updated: 2026-04-16
## 1. Background
diff --git a/docs/SKILL_TYPE_ABSTRACTION_DESIGN_ZH.md b/docs/SKILL_TYPE_ABSTRACTION_DESIGN_ZH.md
index db1f1ff4..41ec53b4 100644
--- a/docs/SKILL_TYPE_ABSTRACTION_DESIGN_ZH.md
+++ b/docs/SKILL_TYPE_ABSTRACTION_DESIGN_ZH.md
@@ -1,5 +1,11 @@
# Skill 类型抽象设计
+> ⚠️ **这份文件描述的是 v1(Python)时代的形状**:目录是 `colleagues/{slug}`、入口是
+> `tools/*.py`、实现是 Python。v2 已迁移到 Node 单栈:目录是 `skills//`、
+> 入口是 `bin/distilly.mjs`、性格层是 Layer 0–5、schema 是 v4。
+> **产品意图仍然有效,文件路径与实现细节请以 `docs/v2/CONTRACT.md` 与 `docs/v2/STATUS.md` 为准。**
+> 迁移对照表见 `SKILL.md` 与 `docs/v2/MIGRATION.md`。
+
最后更新:2026-04-16
## 1. 背景
diff --git a/docs/evidence/pr-01-node-core.md b/docs/evidence/pr-01-node-core.md
new file mode 100644
index 00000000..6797d69e
--- /dev/null
+++ b/docs/evidence/pr-01-node-core.md
@@ -0,0 +1,53 @@
+# PR-01 · Node 单栈基座:入口 CLI + Skill 内核 + 安装器 + 拼音 + 测试移植
+
+- 分支:`ds/01-node-core`(12 个提交,已本地合并进 `dot-skill-test`)
+- 依赖:无。这一条是并行工作的基座:契约、命令注册表、验收脚本都由它落下来
+- 交付:38 个文件 / +11 050 行
+
+## 1. 变更
+
+| # | 提交 | 内容 |
+| --- | --- | --- |
+| 1 | `c383fca` | `src/skill/writer.mjs` + `src/skill/slug.mjs`:Python `skill_writer.py` 的 Node 移植,含拼音 slug |
+| 2 | `3337c39` | `src/skill/versions.mjs`:版本归档(list / backup / rollback / cleanup),归档时间戳按 UTC 固定 |
+| 3 | `579b159` | 归档列表确定性:同一天两次 `version list` 输出一致 |
+| 4 | `f94675d` | `scripts/parity.mjs`:与迁移前 Python 的**逐字节 parity** 证据(按 rev 跑,不进日常门禁) |
+| 5 | `441ceaf` | `src/commands/skill.mjs`:`skill create\|update\|list\|version` 接进注册表 |
+| 6 | `9ebfa6e` | `src/install/hosts.mjs`:8 个宿主安装器合并成一个模块(路径矩阵单一出处) |
+| 7 | `3436730` | 安装器测试移植到 `node --test`(claude / codex / openclaw / hermes) |
+| 8 | `43c837e` | 已注册命令的 `--help` 打印双语两段 |
+| 9 | `f6dcf87` | `listCommands()` 暴露命令名(供 doctor / prompt-lint / 审计共用) |
+| 10 | `f97bc0c` | `install` / `uninstall` / `doctor` / `legacy` 适配器(旧 `python3 tools/*.py` 调用转发 + deprecation 警告) |
+| 11 | `28b0c32` | `assets/pinyin.json`:从 Unihan 生成,去掉 `pypinyin` 运行时依赖 |
+| 12 | `1da31aa` | 其余 Python 测试套件移植为 `node --test` |
+
+新增文件(节选):`src/commands/{index,skill,install,doctor,legacy}.mjs`、`src/cli/{args,receipt}.mjs`、
+`src/skill/{writer,presets,schema,slug,versions}.mjs`、`src/install/hosts.mjs`、`src/hosts/agents.mjs`、
+`assets/pinyin.json`、`scripts/{generate-pinyin,parity,acceptance}.mjs`、`docs/v2/{CONTRACT,ACCEPTANCE,STATUS}.md`、
+`tests/{dispatcher,commands,cli-lifecycle,skill-writer,pinyin-slug,install-*}.test.mjs`、
+公开语料夹具 `tests/fixtures/public-corpus/synthetic-interview/**`。
+
+## 2. 验收(当前树,可复算)
+
+```bash
+node --test tests/dispatcher.test.mjs tests/commands.test.mjs tests/cli-lifecycle.test.mjs \
+ tests/skill-writer.test.mjs tests/pinyin-slug.test.mjs tests/install-*.test.mjs
+# 33 个测试文件、330 个 test() 块:node --test tests/*.test.mjs → 340 pass / 0 fail
+node bin/distilly.mjs --help # 22 个命令名,全部有中英两段
+node bin/distilly.mjs doctor # 宿主矩阵 8 个宿主,逐个报告是否已安装
+node scripts/parity.mjs # 历史 parity 证据(需要旧 rev)
+```
+
+要点:**零运行时依赖**(`package.json` 无 dependencies);入口唯一(`bin/distilly.mjs`);
+`--json` 在任何命令上只输出一个对象(`tests/dispatcher.test.mjs` 断言);
+命令注册表是唯一注册点,两段式命令名优先(`skill create` 赢过 `skill`)。
+
+## 3. 回滚
+
+- 逐提交可 revert;`src/commands/legacy.mjs` 单独 revert 会让旧 `tools/*.py` 调用直接报未知命令。
+- `assets/pinyin.json` 是生成物:`node scripts/generate-pinyin.mjs` 可重现(`--check` 防漂移)。
+
+## 4. 已知缺口
+
+- `scripts/parity.mjs` 需要一份迁移前的 rev 才能跑:parity 是历史证据,不是日常门禁。
+- 这一条只交付 skill/install/doctor 内核;`harvest` / `parse-*` / `view` / `collect` 由 #02/#03/#07 交付。
diff --git a/docs/evidence/pr-02-parse-zero-cred.md b/docs/evidence/pr-02-parse-zero-cred.md
new file mode 100644
index 00000000..b962cd52
--- /dev/null
+++ b/docs/evidence/pr-02-parse-zero-cred.md
@@ -0,0 +1,59 @@
+# PR-02 · 零凭据解析:`knowledge/` 账本 + 锚点 + chat/subtitle/archive 解析
+
+- 分支:`ds/02-parse-zero-cred`(6 个提交,已本地合并进 `dot-skill-test`)
+- 依赖:契约(`docs/v2/CONTRACT.md`,由 #01 冻结)
+- 交付:31 个文件 / +5 905 行
+
+## 1. 变更
+
+| # | 提交 | 内容 |
+| --- | --- | --- |
+| 1 | `05ff594` | 冻结 v2 契约:磁盘布局、回执形状、密钥纪律、computer-use 同意 |
+| 2 | `6f95373` | `src/knowledge/store.mjs`:`knowledge/raw/` 字节保险库(逐字落盘 + 读回校验 + 路径逃逸拒绝) |
+| 3 | `66bc96f` | `src/knowledge/{anchors,ledger}.mjs`:段落锚点分配、只增账本、`units`/`anchor_detail`、去重(sha256 + origin) |
+| 4 | `34e20a3` | `src/parse/subtitle.mjs`:`.srt`/`.vtt` → 一条 cue 一个锚点单元(含说话人、时间码、字节区间) |
+| 5 | `1fd53af` | `src/parse/common.mjs`:零依赖读共享 zip 容器(中央目录、CRC 校验、成员流式解压) |
+| 6 | `6c42d88` | `src/parse/chat.mjs`:ChatGPT / Claude / Slack / Telegram / Discord / Instagram 导出,其余按名拒绝 |
+
+新增文件:`src/knowledge/{store,anchors,ledger}.mjs`、`src/parse/{common,chat,subtitle}.mjs`、
+`tests/{knowledge-store,knowledge-anchors,knowledge-ledger,parse-chat,parse-subtitle}.test.mjs`、
+`tests/fixtures/parse/{chat,subtitle}/**`、`.gitattributes`(夹具按字节保真,禁换行转换)。
+
+## 2. 磁盘契约(这一条定下来,后面所有分支都按它写)
+
+```
+skills///knowledge/
+ raw//… 原样字节,只增不改
+ text/.md 归一化正文,段落锚点 [k0012] / [k0012:t3]
+ index.json 账本:{id,kind,origin,fetched_at,bytes,sha256,credentialed,method,warnings[]}
+```
+
+- **锚点必须能回指**:`resolveLedgerAnchor(ledger, anchor)` 返回文本与字节区间;
+ 容器里读出来的文本(OOXML 成员、去标签的 HTML)标 `synthetic` 且**报 null 字节区间**,不伪造偏移。
+- **幂等**:同一份字节(sha256 相同且 origin 相同)重复导入不新增条目。
+- **响亮拒绝**:不认识的格式按文件名进 warnings,不静默跳过。
+
+## 3. 验收(当前树,可复算)
+
+```bash
+node --test tests/knowledge-store.test.mjs tests/knowledge-anchors.test.mjs \
+ tests/knowledge-ledger.test.mjs tests/parse-chat.test.mjs tests/parse-subtitle.test.mjs
+node scripts/acceptance.mjs # harvest / 账本 / 幂等 / 锚点回指四个阶段
+```
+
+`scripts/acceptance.mjs` 里与本条直接相关的断言:回执形状(sha256 + 字节数)、
+重复 harvest 幂等、账本里有锚点、派生结论锚点全部可回指。
+
+## 4. 后续修复(合并后由集成轮补上,见后续 PR 文档)
+
+- `assignAnchorsToText` 曾把段落渲染成 `k0012 text`(无方括号),而本条的读者要求
+ `[k0012] text`:派生层因此恒为空。修法与前后对比见
+ `docs/evidence/pr-09-evidence-spine-blind-test.md`。
+- 归一化正文曾丢掉说话人与时间(本条的 parser 有元数据,但没进正文):修法与前后对比见
+ `docs/evidence/pr-11-attribution.md`。
+
+## 5. 回滚
+
+- `src/knowledge/**` 与 `src/parse/**` 是后面所有解析命令的地基:回滚本条会让
+ `harvest` / `parse-*` / `retrospect` 全部失效,需回滚到 #01 的 CLI 骨架状态。
+- `.gitattributes` 的字节保真规则不要单独 revert:夹具在 CRLF 平台上会被改写,测试随之漂移。
diff --git a/docs/evidence/pr-03-render.md b/docs/evidence/pr-03-render.md
new file mode 100644
index 00000000..9a42a79c
--- /dev/null
+++ b/docs/evidence/pr-03-render.md
@@ -0,0 +1,194 @@
+# PR-03 渲染层证据(`ds/03-render`)
+
+纯文字证据。截图/PNG **不入库**,见 §6;本文件只记录数值、命令与结果。
+
+## 1. 变更摘要
+
+单文件 HTML 渲染层:模板是生成物、八段页面、双主题、默认私有、`view check` 结构化诊断、
+`view render` 确定性产物 + 回执、Playwright visual-check 八项。
+
+| commit | 内容 |
+|---|---|
+| `feat(view): add the page template fragments and viewer runtime` | `assets/template.source.html`、`viewer/{sections,theme,export,focus}.js` |
+| `feat(view): generate the single-file template from fragments` | `scripts/generate-template.mjs` + 生成物 `assets/distilly-template.html` |
+| `feat(view): validate view.json against the v2 contract` | `src/views/schema.mjs`(八段/kind/锚点/confidence/12 字原文 + 诊断形状 + 宽容归一化) |
+| `feat(view): render a view.json into a single-file HTML page` | `src/views/render.mjs`(payload 注入、回执、确定性、离线校验) |
+| `feat(cli): register view check and view render` | `bin/distilly.mjs` 增加 `view` 子命令(最小注册,见 §8 缺口 1) |
+| `chore(view): add the playwright visual check script` | `scripts/visual-check.mjs` |
+| `test(view): cover schema diagnostics, rendering and template drift` | `tests/views.test.mjs`、`tests/template.test.mjs` |
+| `docs(view): document the render layer contract` | `docs/v2/RENDER.md` |
+| `docs(evidence): record the pr-03 render evidence` | 本文件 |
+
+未触碰 `src/parse/**`、`src/knowledge/**`、`src/skill/**`、`src/install/**`、`SKILL.md`、`prompts/**`;
+未修改 `docs/v2/CONTRACT.md`。
+
+## 2. 测试命令与结果
+
+```
+$ node --test tests/views.test.mjs tests/template.test.mjs
+# tests 44
+# pass 44
+# fail 0
+# duration_ms 710.41
+```
+
+- `tests/views.test.mjs`(35 项):schema 正/反例、诊断形状逐字段断言、12 字原文边界(11 字不算、12 字算)、
+ `--shareable` 放行、锚点格式/回指、宽容形状归一化(含幂等)、确定性(两次渲染字节相同)、
+ 私有产物不含引文、`--shareable` 产物含引文且回执记录 `inlined_sources[]`、
+ CLI `view check/render --person --json` 与退出码。
+- `tests/template.test.mjs`(9 项):模板 = 碎片构建结果(字节相同)、`--check` 干净退出 0、
+ **碎片改了但模板没重生成 → 非零退出**(在临时目录里改 `viewer/sections.js` 后断言 exit 1,
+ 重新生成后回到 0)、marker 缺失/重复报错、`` 碎片被拒、产物单文件 + CSP + 无 `http(s)://`、
+ 两次渲染字节相同。
+
+## 3. 产物大小与 sha256
+
+| 文件 | 字节 | sha256 |
+|---|---|---|
+| `assets/distilly-template.html`(生成物) | 41673 | `cb88a172694f31eb970d8f323ffefc1a13319d8aa17c84705ead959d1c738798` |
+| `assets/template.source.html` | 10955 | `483189a41f196c7434aa359881a5c34a265db9d25fea3744aa788a776225f1db` |
+| `viewer/sections.js` | 15585 | `80967985aee069058589ac5dff5ce4b5aada3b9af40cf6b5039753700017aaa7` |
+| `viewer/theme.js` | 4073 | `3f9e73ee72a3dfbf4ca22e5b14551458b1b7251750e6740ab2db04a99bfc474a` |
+| `viewer/export.js` | 6323 | `5eab79b4ceb5f3dabb5a4841c63c1f736bc3a64afdc0528b39fdb6900bccb06f` |
+| `viewer/focus.js` | 4592 | `5a17e146cb6a1044ae94d9928cb5bb7198bcd1e2c672dd5d33e656e1a9456c91` |
+
+示例人 `zhang-san`(fixture 在 `/tmp/dst-fixture`,不入库;8 个锚点、18 条结论):
+
+| 产物 | 字节 | sha256 |
+|---|---|---|
+| `views/zhang-san.html`(默认私有) | 48338 | `2a0ace948a6027dfec12c77e0052c5c0f035fa3742a39fedb7ec47c9e9f46db0` |
+| `views/zhang-san.shareable.html`(`--shareable`) | 49244 | `782118192556b49188bfd97da733e389947dc6be0e8bb6b010f9002581b5b9e1` |
+| `evidence/renders/receipt.json`(私有运行) | 1505 | `d4aea409510c0535258266b46dd0726890cce5eac6a95109c8e4cdd1ceac911d` |
+| `evidence/renders/receipt.json`(shareable 运行) | 3450 | `651cdd69f131d3c6130698211a8934a9456ce8bf7340df3ed770e27901906ba4` |
+| 验收语料形状 `lin-gong.html`(无 `meta.slug`/无 `evidence[]`) | 46402 | `cce9cdb8c850da34917b430ec462e8c370f6adb0ce605733f933ae1477bdba4e` |
+
+确定性:同一输入连续两次 `view render`,HTML 与 receipt 的 sha256 均不变(单元测试与 §4 手工各验一次)。
+私有 HTML 中检索三条引文均**不存在**;`--shareable` HTML 中三条全部存在,且
+`receipt.inlined_sources[]` 长度 8(每条含 `anchor/source/kind/path/quote_sha256/quote_bytes`)。
+
+## 4. visual-check 八项(手工跑)
+
+命令:
+
+```
+DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> \
+ node scripts/visual-check.mjs --out /tmp/dst-evidence/pr-03/
+```
+
+对三个页面各跑一次,均 **exit 0、8/8 通过**;下面逐条列出私有示例页的结果(`--json` 回执在 `/tmp/vc-private.json`)。
+
+| # | 项 | 结果 | 数值 |
+|---|---|---|---|
+| 1 | console 无 error/warning | ✅ | 0 条 console / 0 个 pageerror / 0 个失败请求 |
+| 2 | 八段非空 | ✅ | 8 段 / 26 条 / 附录 8 锚点 |
+| 3 | 无横向溢出 | ✅ | 最大溢出 0px @ 1280/768/375px |
+| 4 | 双主题对比度 | ✅ | 最小对比度 6.00:1;背景亮度 light 1.0 → dark 0.0118;`light→dark` 手动切换生效 |
+| 5 | 锚点可定位到附录 | ✅ | 8/8 个锚点可聚焦(含回指链接),正文引用无悬空 |
+| 6 | 零网络请求(离线 + CSP) | ✅ | 0 个外部请求 / 静态外链 0 个 / CSP 存在 |
+| 7 | `@media print` 不裁切 | ✅ | 8 段可见 / 正文 1450 字符(与屏幕一致)/ 溢出 0px |
+| 8 | 出 PNG | ✅ | 4 张 → `/tmp/dst-evidence/pr-03` |
+
+另两个页面(同一套断言,逐条全绿):
+
+- `legacy`(`lin-gong`,验收脚本的 `view.template.json` 形状 + 无 `evidence[]`):8 段 / 14 条 / 7 锚点,
+ 最小对比度 6.00:1,0 外部请求。
+- `shareable`(`zhang-san`):8 段 / 26 条 / 8 锚点,附录内联 8 条引文,最小对比度 6.00:1。
+
+**负向对照**(证明断言真的会失败,不是恒真;输出在 `/tmp/dst-tamper-out/`):
+
+| 篡改 | 退出码 | 失败项 |
+|---|---|---|
+| 删除 CSP meta | 1 | 零网络请求(离线 + CSP) |
+| 注入 `https://` 外链 CSS | 1 | console 无 error/warning、零网络请求 |
+| payload 删除时间线段 | 1 | 八段非空、锚点可定位到附录、print 不裁切 |
+| 亮色 `muted` 改成近背景色 | 1 | 双主题对比度 |
+| 暗色 `muted` 改成近背景色 | 1 | 双主题对比度 |
+| 手动暗色覆盖失效(`color-scheme` 打回 light) | 1 | 双主题对比度 |
+| 正文引用一个没有附录行的锚点 | 1 | 锚点可定位到附录 |
+| 原始页面(对照组) | 0 | — |
+
+其中「亮/暗 muted 低对比度」与「手动覆盖失效」两组对照是发现真实缺陷后补的:
+最初 `:root { color-scheme: light }` 把 `light-dark()` 钉死在亮色,导致深色页面根本没变色,
+而对比度断言仍然全绿;现在 theme 断言额外比较**实际背景亮度**(light 1.0 / dark 0.0118),
+并断言手动切换必须覆盖系统偏好。
+
+## 5. 其他手工验证
+
+```
+$ node scripts/generate-template.mjs --check
+template is up to date: sha256 cb88a172… (41673 bytes, 4 fragments) # exit 0
+
+$ node bin/distilly.mjs view check zhang-san --root /tmp/dst-fixture
+view check ok: 7/8 segments, 18 items, 8/8 anchors cited, 0 warning(s)
+
+$ node bin/distilly.mjs view check --person lin-gong --root /tmp/dst-legacy --json # 验收调用形式
+{"command":"view check","person":"lin-gong","ok":true,...,"anchors":{"total":7,"cited":7}}
+```
+
+- 指纹/大小一致性:`receipt.outputs[0].sha256` 与实际 HTML 文件 sha256 相同(`tests/views.test.mjs` 断言)。
+- 产物检索 `http://` / `https://` / `` 只有一份、`` 只有一份、CSP 正是
+ `default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'`、
+ 全文没有 `http(s)://`(因此没有外链、没有字体/图片请求)。
+
+## 8. 确定性
+
+- payload 用 `canonicalJson()`(递归按键排序 + 2 空格缩进),回执同样;
+- 产物与回执里没有任何 `new Date()` 之类的时间戳(只用 `meta.generated_at`);
+- 因此同一输入跑两次,HTML 与 receipt 的 sha256 完全相同(`tests/views.test.mjs`、
+ `tests/template.test.mjs` 都断言)。
+
+## 9. visual-check(开发期)
+
+```bash
+DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> \
+ node scripts/visual-check.mjs views/.html --out /tmp/dst-evidence/pr-03/
+```
+
+八项断言(任一项失败 → 退出码 1;PNG 只写 `--out`,默认 `/tmp/dst-evidence/pr-03/`,不入库):
+
+1. `console 无 error/warning`(含 pageerror、失败请求)
+2. `八段非空`(`[data-section]` = 8,每段有内容,附录有锚点行)
+3. `无横向溢出`(1280 / 768 / 375 px)
+4. `双主题对比度`(系统深浅 + 手动切换;背景亮度必须真的翻转;采样对 ≥4.5:1)
+5. `锚点可定位到附录`(每个锚点行可 `#anchor-` 定位并聚焦,且有回指链接;正文引用的锚点必须都有行)
+6. `零网络请求(离线 + CSP)`(除文档自身外 0 请求 + CSP meta 存在 + 无静态外链)
+7. `@media print 不裁切`(八段可见、无滚动裁切、正文字符数与屏幕一致)
+8. `出 PNG`(`view-light.png` / `view-dark.png` / `view-print.png` / `view-mobile-375.png`)
+
+`playwright` 只是**开发期依赖**:缺失时脚本**响亮失败**(退出码 2 + 安装指引),运行时零依赖。
+
+## 10. 已知缺口
+
+- 回执固定写在 `evidence/renders/receipt.json`:同一 slug 先渲染私有、再渲染 `--shareable`,
+ 回执只保留最后一次。需要同时留档时请自行 `cp`(本 PR 未改契约路径)。
+- `view check` 只校验 `view.json` 内部一致性;锚点能否在 `knowledge/index.json` 回指由
+ `docs/v2/ACCEPTANCE.md` 的端到端验收负责(渲染层没有账本输入)。
+- 页面不含富交互(无折叠、无搜索);`export.js` 的「复制 Markdown / 下载 HTML 快照」依赖浏览器
+ 的剪贴板/下载能力,`file://` 下可能被浏览器策略挡住并给出状态提示。
+- 手动主题覆盖记在 `localStorage`(键 `distilly-view-theme`);某些 `file://` 配置禁用存储时
+ 只在当前页面视图内生效。
+
+---
+## English
+
+The render layer ships two commands, `distilly view check` and `distilly view render`.
+`view.json` carries seven authored segments (`portrait`, `communication`, `values`,
+`workstyle`, `relationship`, `boundaries`, `timeline`); the eighth segment — the evidence
+appendix — is derived from `evidence[]` plus the anchors the prose cites, so it can never
+drift from the claims.
+
+`evidence[]` entries are the interface with `ds/06-retrospect`:
+`anchor` (required, `k0012` or `k0012:t3`, traceable to `knowledge/index.json`),
+`source` (required), `kind`, `path`, `id`, `at`, `sha256`, `note`, and `quote`.
+
+* **Private by default**: quotes are stripped from the embedded payload, so the HTML never
+ contains source wording; only conclusions and anchor ids are shown. `--shareable` inlines
+ quotes and records every inlined source (`anchor`, `source`, `kind`, `path`, `quote_sha256`,
+ `quote_bytes`) in `evidence/renders/receipt.json`.
+* **Tolerant input, strict semantics**: alternative shapes (`headline`, `claims[]`, `points[]`,
+ `date`, section aliases such as `voice`/`work`/`relations`, a top-level `slug`) are normalised
+ with one loud `VIEW_SHAPE_NORMALIZED` warning. Kinds, confidence values, anchor syntax and
+ anchor resolution are never relaxed.
+* **Every diagnostic is self-repairing**: `{code, severity, message, subject{path, identity},
+ evidence, supportedFixes[]}`; errors exit 1 and block rendering.
+* **The template is a build artifact**: `assets/template.source.html` plus `viewer/*.js` are
+ joined through fixed markers into `assets/distilly-template.html`;
+ `node scripts/generate-template.mjs --check` exits non-zero on drift.
+* **Deterministic**: canonical JSON, no timestamps, so two runs produce byte-identical HTML
+ and receipts.
+* **visual-check** (development only, playwright) asserts eight contracts — silent console,
+ eight non-empty segments, no horizontal overflow, dual-theme contrast, resolvable anchors,
+ zero network requests, unclipped print layout, PNG evidence — and fails loudly when
+ playwright is missing.
diff --git a/docs/v2/STATUS.md b/docs/v2/STATUS.md
new file mode 100644
index 00000000..213a7333
--- /dev/null
+++ b/docs/v2/STATUS.md
@@ -0,0 +1,137 @@
+# dot-skill v2 · 当前状态
+
+> 这份文件是**这一支的权威状态说明**:现在能做什么、怎么验、哪些还没做到。
+> 最后更新:2026-09-15(集成分支 `dot-skill-test`)。
+> 契约 `docs/v2/CONTRACT.md` · 验收 `docs/v2/ACCEPTANCE.md` · 渲染 `docs/v2/RENDER.md` ·
+> 提示词 `docs/v2/PROMPTS.md` · 宿主矩阵 `docs/v2/HOSTS.md` · 迁移 `docs/v2/MIGRATION.md`。
+> 各功能的交付说明与前后对比在 `docs/evidence/pr-NN-*.md`。
+
+## 1. 这是什么
+
+把原材料(聊天导出、邮件、字幕、文档、归档、需要 key 的渠道)蒸馏成一个**可调用的人物
+Skill**,外加一份**每条结论都能回指到原文**的画像页。
+
+| 交付物 | 路径 | 说明 |
+| --- | --- | --- |
+| 人物 Skill | `skills///SKILL.md`(+ `work.md` `persona.md` `work_skill.md` `persona_skill.md` `manifest.json` `meta.json`) | 可装进宿主直接运行;Part A 工作能力 + Part B 人物性格(Layer 0–5) |
+| 画像页 | `views/.html` + `evidence/renders/receipt.json` | 单文件、离线、双主题;每条结论带 `[k00NN]` 锚点 |
+
+三个 family:`colleague` / `relationship` / `celebrity`。
+八个宿主:`claude-code` `codex` `opencode` `openclaw` `hermes` `deepseek-harness` `grok-build` `pi`。
+
+**护城河不是"像不像",是"凭什么这么说"**:每条结论可回指到原文的字节区间,派生可复跑
+(同一输入两次字节相同),产物被机械门禁压住。
+
+## 2. 主线:五步
+
+`Collect → Derive → Read → Distill → Render`(细则见 `SKILL.md`)
+
+| 步骤 | 产物 | 机械保证 |
+| --- | --- | --- |
+| 1 Collect | `knowledge/raw/**`、`knowledge/text/*.md`、`knowledge/index.json` | 回执 + 幂等 + 账本 sha256 |
+| 1.5 语料体检 | 无新文件,`doctor` 回执里的 `shape[]` | **前置门槛**:`distilly doctor --require-shape`;`verdict=FAIL` 时必须停下并给出理由 |
+| 2 Derive | `evidence/derived/*.json`(7 个维度) | 两次运行字节相同 + 派生锚点全部可回指 |
+| 3 Read | 无新文件,产出"读了什么"的复述 | **靠提示词约束,无机械门禁**(已知缺口 7.2) |
+| 4 Distill | `work.md`、`persona.md` → `distilly skill create` | **产物已进验收**:产物齐 / Layer 0–5 齐 / 悬空锚点=0 |
+| 5 Render | `views/.view.json`、`views/.html`、`evidence/renders/receipt.json` | view check + 两次渲染字节相同 + 单文件无外链 + CSP + visual-check 8 项 |
+
+## 3. 怎么跑
+
+```bash
+# 零依赖:Node >= 20,无运行时 npm 依赖
+node bin/distilly.mjs <子命令> # 唯一入口(该文件也带可执行位)
+
+# 宿主里(以 DeepSeek Harness 为例)
+node bin/distilly.mjs install deepseek-harness # → $DSH_HOME/skills/distilly
+# DSH 会 watch 技能目录,装完即被发现(实测同一会话内生效);
+# Skill 正文写明 CLI 的确切调用形式:node "{distilly_skill_root}/bin/distilly.mjs" <子命令>
+```
+
+**路径约定(一条规则)**:`--base-dir <工作区>` = 工作区根(下面有 `skills/`),
+**每一条命令**一致;`--skills-dir `(`skill *`)与 `--dir <人物目录>`(`retrospect`)
+用于直接指名更细的一层;`view` 另接受同义别名 `--root`。同时给 `--base-dir` 与
+`--skills-dir` 会报错,不会猜。
+
+## 4. 怎么验(当前实测数字)
+
+```bash
+npm test # CI 的同一条命令
+node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-interview --person lin-gong
+node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/us-house-floor-2009-07-29 --person us-house
+DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> node scripts/audit-objective.mjs
+node scripts/check_release.mjs && node scripts/prompt-lint.mjs && node scripts/generate-template.mjs --check
+```
+
+| 门禁 | 结果 |
+| --- | --- |
+| `npm test` | 391 / 391(Node 20 与 22 各一遍) |
+| `scripts/acceptance.mjs` | **18 / 18**,两份公开语料各一遍 |
+| `scripts/audit-objective.mjs` | 15 / 15 条满足(本地要求工作树干净且已推送) |
+| `scripts/check_release.mjs` | 7 / 7 |
+| `scripts/prompt-lint.mjs` | 0 finding / 26 文件 |
+| `scripts/generate-template.mjs --check` | 无漂移 |
+| GitHub Actions | Node 20 / Node 22 / Acceptance 三个 job 全绿 |
+
+> 2026-09-15 补上的两道门禁:**语料体检**(`doctor --require-shape`:单元数、可归属比例、
+> 最活跃者占比,FAIL 必给理由——它拦住的正是"拿议会记录蒸出会议室的画像"那类错误)与
+> **引用判定**(`doctor` 现在把生成物里的引用一并计入,悬空引用 → `ok:false`、exit 1)。
+
+验收 18 项里最后 6 项是**交付物与语料门禁**(判据在 `scripts/skill-artifacts.mjs`,纯字符串入参、
+可单测、带反例):产物齐 / PART A·B·运行规则在 / Layer 0–5 齐且 Layer 0 有规则 /
+锚点悬空=0 / doctor 报回指率。
+
+## 5. 语料形状(决定成败的一条)
+
+产品要的是**一个人的一手产出**:本人著作、演讲与访谈字幕、本人社媒。
+会议记录与多人群聊是**混合流水**,必须先有说话人归属才能切出一个人——解析层现在能做到
+(WebVTT voice / `Name:` 前缀 / 全角冒号 `说话人 1:` / 句中换人),但归属率取决于源本身:
+真实 C-SPAN 字幕 47 分钟里只有 84 处说话人标签,解析层取到 82 条,覆盖率约 6%。
+
+**结论:选对语料比优化解析器重要一个数量级。** 产品现在会在 Step 1.5 拦住错误形状
+(`distilly doctor --require-shape`),上面这份 C-SPAN 语料的实测判决是
+`FAIL units=2744 speakers=18 attributed=175(3%)`,理由写明"先补这个人自己的一手产出"。
+
+## 6. 分支与 PR 清单
+
+| 对象 | 数量 | 状态 |
+| --- | --- | --- |
+| 本地 `ds/NN-*` 分支 | 23 | 每条都有 `docs/evidence/pr-NN-*.md`(审计第 11 行按分支全量检查) |
+| 子功能 PR `#166`–`#184` | 19 | **堆叠**(base 是上一条 `ds/*` 分支);见下方三组状态 |
+| 集成分支 PR `#185` | 1 | 目标 `dot-skill`;CI 绿 |
+| 集成分支 | `dot-skill-test` | 本文件描述的对象 |
+
+子功能 PR 的三组状态(2026-09-15 首次真正跑 CI 之后):
+
+| 组 | 分支 | CI | 说明 |
+| --- | --- | --- | --- |
+| **A. 可评审** | `ds/13` `ds/15` `ds/16` `ds/17` `ds/18` `ds/19` `ds/20` `ds/21`(8 条) | **全绿**(Node 20 / Node 22 / Acceptance) | 已并入集成分支上的 3 个修复提交(Node 20 的 `npm test`、审计推送行、测试读真实 home / 大小写断言) |
+| **B. 一条结构性红** | `ds/12-note`(1 条) | 4 红 2 绿 | 补了同样 3 个修复后仍剩 1 条:该分支的 `check_release` 要求 `docs/v2/IDENTITY.md`,而那是 `ds/15` 才交付的文件 —— 重建快照混了时代 |
+| **C. 快照不自洽** | `ds/01`–`ds/11`(10 条) | 30–79 红 | 分支的树与它自带的测试对不上(例:`ds/01` 只有 20 个测试文件,集成分支有 86 个),**不作为合并入口**;功能已包含在 `dot-skill-test` 与后续分支里 |
+
+原始 7 项任务(`ds/01`–`ds/07`)的交付与验证方式见各自的 `docs/evidence/pr-NN-*.md`。
+
+## 7. 已知缺口(没做到的部分,按严重度)
+
+1. **效果数字不可用 —— 装置已就绪,只差一个"不是蒸馏者的"裁判**。
+ 上一次真实结果(`docs/evidence/pr-10-blind-test-runs.md`):run 2 evidence 命中率 0.563 /
+ control 0.500,**四臂 0 编造**;只有 10 条 cue / 10 条断言,差异与裁判方差同量级。
+ 2026-09-15 已在**右形状语料**(synthetic-interview,76 单元)上重跑机械部分并交付一个盲测包:
+ A/B 按时间码切好(28 / 10 条)、实验组页面已定稿(私有模式校验:A 段 24 条原句 **0 条**
+ 出现在页面里,25 个锚点)、对照组裸 prompt 画像已写、裁判题与评分模板齐备。
+ **剩下的两步必须由另一个模型或人来完成**:裁判(写 10 行「特征 | 预测」)与核对者(唯一读 B 的角色)。
+ 本机只注册了一个模型(`deepseek-official/deepseek-flash`),同一模型既蒸馏又裁判的数字没有意义。
+ 包的位置与确切命令见随包 `README.md`(`/tmp/dst-evidence/blind-2026-09-15/`,本地证据不入库)。
+2. **Step 3(Read)没有机械门禁**,全靠提示词自觉;Step 1 / 1.5 / 2 / 4 / 5 有判据。
+3. **10 个 `ds/*` PR 的树与它们自带的测试不自洽**(`ds/01`–`ds/11`):CI 30–79 红,
+ 是快照本身不完整(例:`ds/01` 只有 20 个测试文件,集成分支有 86 个)。
+ 其中 9 条已并入集成分支的修复、8 条 CI 转绿、`ds/12` 剩 1 条结构性失败(见第 6 节的分组表);
+ 剩下这 10 条**不打算修**——它们是重建时的不完整快照,功能已包含在集成分支里。
+4. **19 个 PR 的 base 结构与流程要求不符**:要求是"PR 到 `dot-skill-test`",实际是堆叠到
+ 上一条 `ds/*` 分支(`ds/01` 的 base 甚至是 `pre-v2-baseline`)。CI 触发名单已扩到
+ `ds/**` 并推到各分支,所以现在跑得起来;base 结构未改。
+5. **`docs/PRD.md` 与 `docs/SKILL_TYPE_ABSTRACTION_DESIGN*.md` 描述的是 v1(Python)形状**
+ (`colleagues/{slug}`、`tools/*.py`),未随 v2 重写;两份文件顶部已加说明指向 v2。
+6. **`docs/evidence/pr-NN-*.md` 是各功能当时的历史证据**:其中 `pr-21` 与 `pr-13` 的结论
+ 已被后续工作推翻,两份文件末尾都补了更正章节;其余文件的数字仍然成立。
+7. **`README.md` 已分区**:本分支(Skill + CLI)在前,`distilly-plugin` 那条 Plugin 线
+ 明确划到「另一条产品线」标题下并注明不在本分支运行。
diff --git a/package.json b/package.json
index 453f911c..fc2c7a26 100644
--- a/package.json
+++ b/package.json
@@ -8,17 +8,19 @@
},
"files": [
"bin/",
+ "src/",
+ "assets/",
+ "scripts/",
"SKILL.md",
"prompts/",
"references/",
- "tools/",
- "requirements.txt",
"INSTALL.md",
"INSTALL_EN.md",
"LICENSE",
"CITATION.cff"
],
"scripts": {
+ "test": "node --test tests/*.test.mjs",
"prepack": "node bin/distilly.mjs --check-package"
},
"keywords": [
@@ -41,9 +43,10 @@
"url": "https://github.com/titanwings/distilly/issues"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
+
diff --git a/prompts/celebrity/budget_unfriendly/audit.md b/prompts/celebrity/budget_unfriendly/audit.md
index 89b4c0a7..b4d96992 100644
--- a/prompts/celebrity/budget_unfriendly/audit.md
+++ b/prompts/celebrity/budget_unfriendly/audit.md
@@ -1,3 +1,37 @@
+# 名人研究审计 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+对 6-track 研究笔记做审计:给出明确的 `PASS` / `FAIL`,检查信源层级合规(无黑名单)、primary 比例 > 50%、品味原则遵守情况与冷门人物评估;FAIL 时输出 Backfill Tasks。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 审计结论必须是明确的 `PASS` 或 `FAIL`,并逐项列出检查依据与锚点。
+5. FAIL 时先按 Backfill Tasks 补齐对应 track,不得跳到 synthesis。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止为了让审计通过而修改或美化研究笔记。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Research Audit Prompt
## Task
@@ -130,3 +164,29 @@ Pass the audit when:
## Output Constraint
Write in the user's language.
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. The audit verdict must be an explicit `PASS` or `FAIL`, with each check listed together with its evidence and anchors.
+5. On `FAIL`, backfill the named tracks from the Backfill Tasks before any synthesis.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never edit or prettify the research notes just to make the audit pass.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/budget_unfriendly/persona_analyzer.md b/prompts/celebrity/budget_unfriendly/persona_analyzer.md
index 2c93f8b4..eae2b437 100644
--- a/prompts/celebrity/budget_unfriendly/persona_analyzer.md
+++ b/prompts/celebrity/budget_unfriendly/persona_analyzer.md
@@ -1,3 +1,37 @@
+# 名人 Persona 分析 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+深度模式的分析:从 6-track 笔记、合并摘要、审计与 synthesis 中提取候选心智模型与决策启发式,并为每条结论标注 source weight 与锚点。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每条 evidence 标注 source weight (1-7),并给 `文件 + 锚点`。
+5. 候选心智模型必须写明 triple gate 判定(cross-context recurrence / generative power / exclusivity)与 failure modes。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止只依赖长段直接引用;禁止把单一语境的模式写成心智模型。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Persona Analyzer
## Task
@@ -88,3 +122,29 @@ rather than relying on training corpus alone.
- Do not rely on long direct quotes
- Separate evidence from inference in every section
- If a dimension has thin evidence, mark it explicitly rather than fabricating depth
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every evidence item carries a source weight (1-7) plus `file + anchor`.
+5. Candidate mental models state their triple-gate verdict (cross-context recurrence / generative power / exclusivity) and failure modes.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never rely on long direct quotes; never promote a single-context pattern into a mental model.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/budget_unfriendly/persona_builder.md b/prompts/celebrity/budget_unfriendly/persona_builder.md
index 0aa4c043..8c88c230 100644
--- a/prompts/celebrity/budget_unfriendly/persona_builder.md
+++ b/prompts/celebrity/budget_unfriendly/persona_builder.md
@@ -1,3 +1,37 @@
+# 名人 Persona 生成 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+深度模式的 persona 生成:Layer 0–7 + 认知时间线 + Correction Log,每个结论可回溯到 6-track 笔记的 source weight 与锚点。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每层结论回溯到 6-track 笔记的 source weight 与锚点;证据薄的地方显式标注。
+5. 保留矛盾与时间演化,不把它们压平成当前状态。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止编造引文、书名、视频标题;禁止用泛化形容词替代可辨识的思维特征。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Persona Builder
## Task
@@ -94,3 +128,29 @@ Include at the end:
## Output Constraint
Write in the user's language.
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every layer traces back to a source weight and anchor in the six-track notes; thin evidence is marked explicitly.
+5. Preserve contradictions and evolution over time instead of flattening them into the present state.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never invent quotes, book titles, or video titles; never replace recognizable thinking with generic adjectives.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/budget_unfriendly/research.md b/prompts/celebrity/budget_unfriendly/research.md
index 0950ecdd..e43da07c 100644
--- a/prompts/celebrity/budget_unfriendly/research.md
+++ b/prompts/celebrity/budget_unfriendly/research.md
@@ -1,3 +1,37 @@
+# 名人研究 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+深度研究:6 条独立 track(著作 / 对话 / 表达 DNA / 决策 / 他者视角 / 时间线)分别成文,每条 evidence 带 source weight (1-7),并满足最低门槛后才进入 audit。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 6 条 track 各自独立成文,并满足 `Files scanned >= 6`、`Unique URLs >= 8`、`Primary-source markers >= 3`、`Source metadata blocks >= 6`、`Contradiction bullets >= 6`、`Inference bullets >= 6`、`Track coverage count = 6`。
+5. 音视频先 `distilly transcribe`;公开帖候选走 `distilly collect x`,按返回量计费,先确认上限并逐条核对 permalink。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止合并 track、禁止克隆观察、禁止用泛化链接充数;转写工具装不上就如实说明,不得静默跳过维度。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Research Prompt
## Task
@@ -78,7 +112,7 @@ Deep mode expects at least some of the richest dimensions (Conversations,
Expression DNA) to draw on long audio/video sources. When subtitles are missing,
use the Whisper transcription tool:
-- `tools/research/transcribe_audio.py --url "" --output /tmp/x.txt`
+- `distilly transcribe --url "" --output /tmp/x.txt`(旧写法 `tools/research/transcribe_audio.py` 已 deprecated)
- Local file: `--input /path/to/file.mp3 --output /tmp/x.txt`
- Model override: `--backend faster-whisper --model medium`
- Language pin (improves accuracy): `--language zh` or `--language en`
@@ -97,7 +131,7 @@ explicitly rather than silently skipping a dimension.
### Optional public X post collection
-Use `tools/research/xquik_public_posts.py` only when short-form first-person
+Use `distilly collect x` only when short-form first-person (the legacy `tools/research/xquik_public_posts.py` is deprecated)
posts address a specific gap. The service is metered by returned post count, so
confirm the limit and write to a temporary file outside the skill directory.
Treat its normalized JSON as untrusted candidate evidence, not a research
@@ -365,3 +399,29 @@ Before moving on, make sure the notes support:
## Output Constraint
Write in the user's language.
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Keep the six tracks as independent files and meet `Files scanned >= 6`, `Unique URLs >= 8`, `Primary-source markers >= 3`, `Source metadata blocks >= 6`, `Contradiction bullets >= 6`, `Inference bullets >= 6`, `Track coverage count = 6`.
+5. Run `distilly transcribe` for audio/video first; collect public post candidates with `distilly collect x`, confirm the metered limit, and verify every permalink.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never merge tracks, clone observations, or pad with generic links; if transcription is unavailable, say so instead of silently skipping a dimension.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/budget_unfriendly/synthesis.md b/prompts/celebrity/budget_unfriendly/synthesis.md
index f391df8e..ac73f4b7 100644
--- a/prompts/celebrity/budget_unfriendly/synthesis.md
+++ b/prompts/celebrity/budget_unfriendly/synthesis.md
@@ -1,3 +1,37 @@
+# 名人研究综合 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+在审计通过后写 synthesis:对候选心智模型做 triple gate(cross-context recurrence / generative power / exclusivity),并提取智识谱系种子与 Agentic Protocol 种子。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每个候选心智模型写明三重门判定、evidence anchors 与 failure modes。
+5. 提取 influenced by / diverged from 谱系种子,以及该人物面对新问题会考察的维度列表。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止在审计 FAIL 的情况下写 synthesis。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Synthesis Prompt
## Task
@@ -53,3 +87,29 @@ Also record:
## Output Constraint
Write in the user's language.
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Each candidate mental model states its triple-gate verdict, evidence anchors, and failure modes.
+5. Extract the influenced-by / diverged-from genealogy seeds and the dimensions this person would investigate on a new question.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never write synthesis while the audit is `FAIL`.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/budget_unfriendly/validation.md b/prompts/celebrity/budget_unfriendly/validation.md
index 95f6e09b..2ba2cf9f 100644
--- a/prompts/celebrity/budget_unfriendly/validation.md
+++ b/prompts/celebrity/budget_unfriendly/validation.md
@@ -1,3 +1,37 @@
+# 名人 Persona 验证 Prompt · budget-unfriendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+对 draft persona 做验证:known-answer check(≥2 题)、edge-case check(1 题)、100 字盲测 voice check、copyright check 与 Agentic Protocol check,并给出明确 `PASS` / `FAIL`。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 五项检查逐项给出结论,并保留原文与锚点作为判定依据。
+5. `FAIL` 时先修 draft 再重新验证,不得带着 FAIL 进入交付。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止为了让验证通过而降低检查标准或删掉失败项。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Budget-Unfriendly Validation Prompt
## Task
@@ -79,3 +113,29 @@ Use this structure:
## Output Constraint
Write in the user's language and keep the review actionable.
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Report every one of the five checks with its verdict, keeping the source text and anchors as the basis.
+5. On `FAIL`, revise the draft and re-validate; never ship with a `FAIL` outstanding.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never lower a check or delete a failing item just to make validation pass.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/intake.md b/prompts/celebrity/intake.md
index d38064aa..4cf2671a 100644
--- a/prompts/celebrity/intake.md
+++ b/prompts/celebrity/intake.md
@@ -1,3 +1,38 @@
+# 名人 Intake Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+名人蒸馏的 intake:确认人物、research profile(默认 `budget-friendly`)与采集策略(local-first / web+local / web-only),支持"按人名直连"或"先做诊断子流程"两条入口;输出中包含冷门人物判定。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 第 4 个问题必须确认 `research_profile`(`budget-friendly` / `budget-unfriendly`)与采集策略,不能默认跳过。
+5. intake 阶段就要记录冷门人物判定(总来源 < 10 条时后续心智模型限制为 2–3 个)。
+6. 用户没提供的字段留空并写 `unknown`,不要替用户推断身份、职业或立场。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止把候选人物(诊断子流程给出的名字)当作已确认对象开始采集。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Intake Prompt
## Goal
@@ -212,3 +247,30 @@ Summary:
Confirm? (confirm / edit [field])
```
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. The fourth question must confirm `research_profile` (`budget-friendly` / `budget-unfriendly`) and the collection strategy; never skip it silently.
+5. Record the cold-figure verdict during intake (below 10 total sources, later mental models are limited to 2–3).
+6. Fields the user did not supply stay empty and are `unknown`; never infer identity, profession, or stance.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never start collection on a candidate name from the diagnostic sub-flow before the user confirms the person.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/merger.md b/prompts/celebrity/merger.md
index 0a16e8ae..720dcae6 100644
--- a/prompts/celebrity/merger.md
+++ b/prompts/celebrity/merger.md
@@ -1,3 +1,38 @@
+# 名人增量合并 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+把新素材或用户纠正并入已校准的名人 persona:Mode A 走新素材研究流程,Mode B 走 Correction Log;只改被触及的层,保留人物的复杂性与时间演化。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. Mode A 的新素材先登记入库:音视频先 `distilly transcribe`,再更新 `knowledge/research/raw/` 下的 dated 目录。
+5. 合并后用 `distilly doctor` 复核证据覆盖率与锚点回指率,之前通过的检查必须仍然通过。
+6. Mode B 的纠正必须逐字记入 Correction Log,并记录触发它的用户原话与时间。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止把新证据"顺带"改写进用户没有纠正的层(no cascade edits)。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Merger
## Task
@@ -16,13 +51,16 @@ right layer without damaging what is already calibrated.
### Mode A — Material-driven update
Triggered when the user supplies fresh research (new interviews, talks,
-writings, subtitles, transcripts via `tools/research/transcribe_audio.py`).
+writings, subtitles, transcripts via `distilly transcribe`; the legacy
+`tools/research/transcribe_audio.py` is deprecated).
Sequence:
1. Run the intake / research pipeline for the **new** material only (keep it
in a dated subfolder under `knowledge/research/raw/` so provenance is clear).
-2. Re-run `tools/research/merge_research.py ` to refresh the merged
+2. Re-run `distilly retrospect` to refresh the derived evidence; the legacy
+ `tools/research/merge_research.py ` is deprecated and has no
+ contract replacement yet.
summary with the added files.
3. Diff the new extraction against the existing `persona.md` before rewriting
anything. Most material will confirm what is there; only a minority will
@@ -168,7 +206,8 @@ trail.
- Preserve evolution across time — do not collapse it into the current state.
- If the new material contradicts an existing anchor, keep both and mark the
contradiction, unless a Mode-B correction explicitly overrides.
-- Re-run `tools/research/quality_check.py --profile `
+- Re-run `distilly doctor` (the legacy
+ `tools/research/quality_check.py --profile ` is deprecated)
after merging. All previously-passing checks should still pass.
---
@@ -201,3 +240,30 @@ trail.
- research_metrics after merge: {paste relevant fields}
- quality_check.py result: {pass/fail with which checks flipped}
```
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Register Mode A material first: run `distilly transcribe` for audio/video, then update the dated folder under `knowledge/research/raw/`.
+5. After merging, re-check evidence coverage and the anchor back-reference rate with `distilly doctor`; previously passing checks must still pass.
+6. Mode B corrections are logged verbatim in the Correction Log together with the triggering user phrase and timestamp.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never cascade edits into layers the user did not correct.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/persona_analyzer.md b/prompts/celebrity/persona_analyzer.md
index c09087fb..8f9b9e6f 100644
--- a/prompts/celebrity/persona_analyzer.md
+++ b/prompts/celebrity/persona_analyzer.md
@@ -1,3 +1,37 @@
+# 名人 Persona 分析 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+从一手材料与合并后的研究笔记中提取心智模型、决策启发式、表达 DNA、矛盾点与诚实边界,用于构建可辨识、非戏剧化模仿的名人 persona。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每个提取目标(心智模型 / 决策启发式 / 表达 DNA / 矛盾 / 诚实边界 / 智识谱系)单独给锚点,或明确写 `unknown`。
+5. 一手的口述材料与二手评论必须分开标注;source weight 冲突时保留权重更高的层级。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止戏剧化模仿或拼贴引文;只保留极短的表达 DNA 片段。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Persona Analyzer
## Task
@@ -157,3 +191,29 @@ Agentic Protocol Seeds:
- Do not turn source notes into stitched quotations
- Preserve contradictions — they are features, not bugs
- If evidence is thin for a dimension, say so explicitly rather than fabricating depth
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every extraction target (mental models / decision heuristics / expression DNA / contradictions / honest boundaries / intellectual genealogy) carries its own anchor, or is explicitly `unknown`.
+5. Keep first-person material separate from secondhand commentary; on a source-weight conflict keep the higher tier.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never turn source notes into stitched quotations or theatrical impersonation; keep only very short expression-DNA phrases.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/persona_builder.md b/prompts/celebrity/persona_builder.md
index d34b0083..fce8e26b 100644
--- a/prompts/celebrity/persona_builder.md
+++ b/prompts/celebrity/persona_builder.md
@@ -1,3 +1,38 @@
+# 名人 Persona 生成 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+把分析结果写成名人 persona 的结构化层:Layer 0–7(核心思维规则、身份、表达 DNA、心智模型、决策启发式、反模式与边界、智识谱系、Agentic Protocol)加认知时间线与 Correction Log。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每一层要么有锚点,要么显式标注"基于有限信息";不得用泛化内容填满。
+5. Agentic Protocol 的维度必须从这个人自己的心智模型推导出来。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止把任何聪明人都会做的通用研究步骤写进 Agentic Protocol。
+7. 禁止把完整 transcript 之类的原文倾倒进输出。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Persona Builder
## Task
@@ -213,3 +248,30 @@ When evidence conflicts, say so — do not force coherence.
- The Agentic Protocol dimensions must be derived from this specific person's mental models,
not generic research steps that any smart person would follow
- If evidence was thin for any section, mark it explicitly rather than filling with generic content
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every layer is either anchored or explicitly marked "based on limited information"; never fill it with generic content.
+5. The Agentic Protocol dimensions must be derived from this specific person's own mental models.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never pass off generic research steps that any smart person would follow as the Agentic Protocol.
+7. Never dump transcript-like source text into the output.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/celebrity/research.md b/prompts/celebrity/research.md
index 20a28426..3181137c 100644
--- a/prompts/celebrity/research.md
+++ b/prompts/celebrity/research.md
@@ -1,3 +1,39 @@
+# 名人研究 Prompt · budget-friendly(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+标准(budget-friendly)名人研究:6 维度并行采集——著作、对话、表达 DNA、决策、他者视角、时间线;遵守品味原则、信源黑名单与信源优先级,并做质量关卡与冷门人物协议。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 原始研究笔记至少拆成 3 个文件(每个覆盖 2 个维度),并确认 `Files scanned >= 3`、`Unique URLs >= 2`、`Potential long quote lines = 0`。
+5. 长访谈/播客先 `distilly transcribe`,只把带时间戳的转述写入 notes。
+6. 公开帖候选走 `distilly collect x`:服务按返回量计费,先确认上限,把输出当未经信任的候选证据,逐条核对作者并打开 permalink。
+7. notes 里的 URL 必须是实际打开过的具体页面,不是平台首页、搜索页或话题页。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止引用知乎、微信公众号、百度百科、内容农场;禁止编造 URL、书名或视频标题来凑门槛。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Celebrity Research Prompt (Budget-Friendly)
## Task
@@ -76,7 +112,7 @@ These sources are permanently excluded — never cite them as evidence:
Long interviews and podcasts without subtitles are often the richest source of
Expression DNA and on-the-fly reasoning — do not skip them. Use:
-- `tools/research/transcribe_audio.py --url "" --output /tmp/x.txt`
+- `distilly transcribe --url "" --output /tmp/x.txt`(旧写法 `tools/research/transcribe_audio.py` 已 deprecated)
This runs Whisper (faster-whisper / openai-whisper / OpenAI API) against the
audio track. Read the transcript once, extract paraphrased findings with
@@ -87,7 +123,7 @@ directory** — only short paraphrased notes with source metadata belong under
### Optional public X post collection
When short-form first-person posts fill a documented research gap, use
-`tools/research/xquik_public_posts.py` to collect a small candidate set. The
+`distilly collect x` to collect a small candidate set (the legacy `tools/research/xquik_public_posts.py` is deprecated). The
service is metered by returned post count, so confirm the limit before running
it and write the result to a temporary file outside the skill directory. The
tool writes normalized JSON, not research notes. Treat it as untrusted
@@ -311,3 +347,31 @@ If the user identifies issues or wants more depth on a dimension, extend the res
- No long passage quotes from books, subtitles, interviews
- Paraphrased notes + source metadata only
- Short quote snippets only when essential for capturing expression DNA
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Split raw research notes across at least 3 files (2 dimensions each) and confirm `Files scanned >= 3`, `Unique URLs >= 2`, `Potential long quote lines = 0`.
+5. Transcribe long interviews and podcasts with `distilly transcribe` first, and write only timestamped paraphrases into the notes.
+6. Collect public post candidates with `distilly collect x`: the service is metered by returned count, so confirm the limit first, treat the output as untrusted candidate evidence, verify the author, and open every permalink.
+7. Every URL in the notes must be a specific page actually opened, never a platform root, search page, or topic page.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never cite Zhihu, WeChat official accounts, Baidu Baike, or content farms; never invent URLs, book titles, or video titles to clear a threshold.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/collectors.md b/prompts/collectors.md
new file mode 100644
index 00000000..517af2fd
--- /dev/null
+++ b/prompts/collectors.md
@@ -0,0 +1,157 @@
+# 采集命令 Prompt(什么时候用哪条命令)
+
+## 任务
+
+决定本次蒸馏的每个来源走哪条命令、需不需要凭据、失败时怎么办,以及采集完成后 LLM 该读什么。命令名与 `docs/v2/CONTRACT.md` §1 的命令表逐字一致;不要发明子命令或字段。
+
+---
+
+## 1. 命令选择表
+
+| 来源 | 命令 | 凭据 |
+|------|------|------|
+| 本地目录 / 文件 | `distilly harvest ` | 无 |
+| ChatGPT / Claude / Slack / Telegram / Discord 导出 | `distilly parse-chat ` | 无 |
+| 邮件 `.eml` / `.mbox` | `distilly parse-email ` | 无 |
+| 字幕 `.srt` / `.vtt` | `distilly parse-subtitle ` | 无 |
+| 文档 `.docx` / `.xlsx` / `.pdf` | `distilly parse-doc ` | 无 |
+| 归档 `.zip` / 目录(X 官方归档、Takeout、社交平台导出) | `distilly parse-archive ` | 无 |
+| 音视频 | `distilly transcribe ` | 可选后端(OpenAI 兼容 HTTP 或宿主能力) |
+| 飞书 / Slack / 钉钉 / X / Discord / Reddit / Notion / Gmail | `distilly collect ` | key / OAuth |
+| 浏览器 computer use | `distilly collect x --mode browser --consent ` | 同意 token(见 `prompts/computer-use.md`) |
+| 用户直接粘贴的文字 / 截图 | `distilly note --from ` | 无(`method:"model-read"`) |
+
+选择顺序:**先零凭据,再凭据渠道,最后 computer use**。已经有导出包或本地文件时,不要为了"更全"去动账号。
+
+---
+
+## 2. 需要 key 的渠道:先征求同意
+
+1. 说明三件事:要读哪个渠道、能拿到什么(消息 / 文档 / 表格)、范围(谁的数据、大概多少条)。
+2. 说明凭据从哪来:只从 `~/.distilly/*_config.json` 或环境变量读取;回执、日志、对话里只出现配置文件名,永不出现值。
+3. 得到明确同意后再执行 `distilly consent grant`,然后 `distilly collect `。
+4. 用户可以用 `distilly consent list` 查看已授予的范围,用 `distilly consent revoke` 撤销。
+5. 用户不同意或没配置凭据时:该渠道进 `unavailable[]`,并在汇报里给出补救步骤,然后改走零凭据路径(上传导出 / 粘贴 → `distilly note --from `)。
+
+---
+
+## 3. 失败时的行为
+
+- 命令非零退出:把**命令原文 + stderr + 补救步骤**一起报给用户,然后停下等指示。
+- 绝不静默降级、绝不伪造来源:没有拿到的渠道就写进 `unavailable[]`,并在 `warnings[]` 里说明原因。
+- 缺凭据 → 说清要配置哪个配置文件;登录态失效 → 说明需要用户重新登录,不要反复重试刷屏。
+- 部分成功(例如消息拿到、文档被权限拦住):逐项说明哪一部分落地、哪一部分没有,不要把部分当全部。
+- 采集后账本是唯一事实来源:`knowledge/index.json` 里没有条目的东西不算已落地来源。
+
+---
+
+## 4. 采集之后 LLM 读什么
+
+1. `knowledge/index.json` —— 账本,每条来源的 `id`、`kind`、`origin`、`fetched_at`、`bytes`、`sha256`、`credentialed`、`method`、`warnings[]`。
+2. `knowledge/text/.md` —— 归一化正文,段落锚点形如 `[k0012]` / `[k0012:t3]`。
+3. `knowledge/raw//...` —— 原样字节,只读、只增不改,不要改写或删除。
+4. 先跑 `distilly retrospect`,再读 `evidence/derived/*.json`(每条结论带 evidence 锚点)。
+5. 最后用 `distilly doctor` 看证据覆盖率、不可用渠道、锚点回指率、computer-use 占比。
+6. 引用规范与"事实 / 候选"的区分见 `prompts/retrospection.md`。
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 选命令前先判断凭据需求;需要 key / OAuth 的渠道先征求同意,再 `distilly collect`。
+5. 采集完成后按上面第 4 节顺序读文件,并用 `distilly doctor` 复核。
+6. 渠道不可用时如实写 `unavailable[]`,并给用户替代路径。
+
+## 禁止
+
+1. 禁止无证据推断:没采到的内容不能凭印象补写。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;所有网络采集只走 `distilly collect` / `distilly harvest` / `distilly transcribe`。
+5. 禁止把候选(candidate)当结论,尤其是只有 permalink 还没核对的公开帖。
+6. 禁止在用户没同意时用浏览器或账号去抓取,也禁止绕过 `--consent `。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Decide which command each source goes through, whether it needs credentials, what to do when it fails, and what the model reads afterwards. Command names match the command table in `docs/v2/CONTRACT.md` §1 word for word; never invent subcommands or fields.
+
+### 1. Command selection
+
+| Source | Command | Credentials |
+|--------|---------|-------------|
+| Local directory / file | `distilly harvest ` | none |
+| ChatGPT / Claude / Slack / Telegram / Discord export | `distilly parse-chat ` | none |
+| Email `.eml` / `.mbox` | `distilly parse-email ` | none |
+| Subtitles `.srt` / `.vtt` | `distilly parse-subtitle ` | none |
+| Documents `.docx` / `.xlsx` / `.pdf` | `distilly parse-doc ` | none |
+| Archive `.zip` / directory (X archive, Takeout, social exports) | `distilly parse-archive ` | none |
+| Audio / video | `distilly transcribe ` | optional backend (OpenAI-compatible HTTP or host capability) |
+| Feishu / Slack / DingTalk / X / Discord / Reddit / Notion / Gmail | `distilly collect ` | key / OAuth |
+| Browser computer use | `distilly collect x --mode browser --consent ` | consent token (see `prompts/computer-use.md`) |
+| Text or screenshots the user pastes | `distilly note --from ` | none (`method:"model-read"`) |
+
+Order: **zero-credential first, credentialed channels second, computer use last**. When an export bundle or local file already exists, do not touch an account "for completeness".
+
+### 2. Channels that need a key: ask for consent first
+
+1. State three things: which channel will be read, what it yields (messages / documents / spreadsheets), and the scope (whose data, roughly how much).
+2. State where credentials come from: only `~/.distilly/*_config.json` or environment variables; receipts, logs, and chat only ever contain the config file name, never a value.
+3. Only after explicit consent, run `distilly consent grant` and then `distilly collect `.
+4. The user can inspect the granted scope with `distilly consent list` and withdraw it with `distilly consent revoke`.
+5. Without consent or without credentials: put the channel into `unavailable[]`, report the remedy, and switch to a zero-credential route (upload an export / paste → `distilly note --from `).
+
+### 3. Behavior on failure
+
+- Non-zero exit: report the **exact command + stderr + remedy** and stop for instructions.
+- Never degrade silently and never fabricate a source: channels that did not land go into `unavailable[]`, with the reason in `warnings[]`.
+- Missing credentials → name the config file to set up; expired login → tell the user to sign in again instead of retrying in a loop.
+- Partial success (messages landed, documents blocked by permissions): report each part separately; never present a part as the whole.
+- After collection the ledger is the only source of truth: anything without an entry in `knowledge/index.json` is not a grounded source.
+
+### 4. What the model reads afterwards
+
+1. `knowledge/index.json` — the ledger: `id`, `kind`, `origin`, `fetched_at`, `bytes`, `sha256`, `credentialed`, `method`, `warnings[]` per source.
+2. `knowledge/text/.md` — normalized text with paragraph anchors such as `[k0012]` / `[k0012:t3]`.
+3. `knowledge/raw//...` — raw bytes, read-only and append-only; never rewrite or delete them.
+4. Run `distilly retrospect` first, then read `evidence/derived/*.json` (every conclusion carries evidence anchors).
+5. Finish with `distilly doctor` for evidence coverage, unavailable channels, anchor back-reference rate, and computer-use share.
+6. Citation rules and the fact/candidate split live in `prompts/retrospection.md`.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Check credential needs before choosing a command; channels needing a key or OAuth require consent first, then `distilly collect`.
+5. After collection, read the files in the order of section 4 above and re-check with `distilly doctor`.
+6. When a channel is unavailable, record it in `unavailable[]` honestly and give the user an alternative route.
+
+### MUST NOT
+
+1. No evidence-free inference: content that was never collected is never reconstructed from memory.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes only through `distilly collect` / `distilly harvest` / `distilly transcribe`.
+5. Never present a candidate as a conclusion, especially a public post whose permalink has not been verified.
+6. Never scrape with a browser or an account without the user's consent, and never bypass `--consent `.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/computer-use.md b/prompts/computer-use.md
new file mode 100644
index 00000000..198cd8b1
--- /dev/null
+++ b/prompts/computer-use.md
@@ -0,0 +1,165 @@
+# Computer Use 同意协议 Prompt(浏览器采集)
+
+## 任务
+
+规定浏览器 computer use 采集的同意流程、只读白名单、资源上限、每屏落盘要求与中断方式。没有同意就不动手:`--consent ` 缺失时命令以 `exit 2` 结束,回执写"等待用户同意"。
+
+---
+
+## 1. 先问再动(同意协议)
+
+动手前必须向用户说明并等待明确同意:
+
+1. **要访问什么**:具体站点与页面(列表形式),不是"某个平台"这种笼统说法。
+2. **要拿什么**:只看指定页面上的公开内容;不点私信、不进设置页、不下载附件。
+3. **资源上限**:默认 **≤20 屏 / ≤10 分钟 / 每分钟 ≤6 次滚动**;需要更多必须先说明理由并再次征求同意。
+4. **会不会落盘**:每屏都会落盘原文 + URL + 时间 + 截图;截图只存本地 `/tmp/dst-evidence//`,不入库(契约 §4:截图/回执/diff 图不提交)。
+5. **怎么停**:用户随时可以说停;每次滚动、每次翻屏之间检查一次中断信号。
+
+得到同意后:
+
+- 用 `distilly consent grant` 记录授权(保存在 `~/.distilly/consent.json`)。
+- 用 `distilly collect x --mode browser --consent ` 执行采集(渠道按契约表选择)。
+- 用户可用 `distilly consent list` 查看、用 `distilly consent revoke` 撤销。
+
+**没有 token 就退出**:不要复用旧 token、不要改走别的抓取方式、不要让用户手抄页面内容冒充采集结果。
+
+---
+
+## 2. 只读白名单
+
+- 只允许访问用户在同意里逐条列出的 URL;遇到跳转到其他站点、登录墙或验证码,停下来问。
+- 只读:不提交表单、不发消息、不点赞、不关注、不改设置、不下载附件。
+- 只采集页面正文与页面自带的公开链接;不猜测隐藏接口。
+- 同一页面重复抓取视为浪费额度,先看账本里是否已有该 URL。
+
+---
+
+## 3. 每屏落盘
+
+每一屏(每次滚动后的可视区域)都必须落盘四件东西:
+
+| 落盘内容 | 位置 |
+|----------|------|
+| 页面原文(归一化文本,带段落锚点 `[k00NN]` / `[k00NN:tM]`) | `knowledge/text/.md` |
+| 原样字节(如需保留页面快照) | `knowledge/raw//...`(只增不改) |
+| URL + 抓取时间 + 方法 | `knowledge/index.json` 的账本条目(`origin`、`fetched_at`、`method`、`credentialed`、`warnings[]`) |
+| 截图 | 本地 `/tmp/dst-evidence//`(不入库) |
+
+采集完成后:先跑 `distilly retrospect`,再读 `evidence/derived/*.json`;用 `distilly doctor` 复核 computer-use 占比与锚点回指率。用户后来补的截图或文字用 `distilly note --from ` 登记,或 `distilly harvest` 入库。
+
+---
+
+## 4. 失败与中断
+
+- 用户说停:立即停止,把已经采集到的部分按上面第 3 节落盘并如实汇报"采了多少屏、停在哪里"。
+- 登录态失效 / 风控 / 验证码:停下报告,让用户决定是否重新登录或改走导出包。
+- 页面结构与预期不符:报告差异,不要猜测内容,也不要用泛化链接充数。
+- 达到上限(20 屏 / 10 分钟 / 每分钟 6 次滚动):停下问用户是否续期,不要自行加量。
+- 任何失败都写进 `warnings[]` / `unavailable[]`,不得静默降级。
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`)。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 动手前先拿到明确同意,并用 `distilly consent grant` 记录;执行时带 `--consent `。
+5. 遵守默认上限 ≤20 屏 / ≤10 分钟 / 每分钟 ≤6 次滚动,并在每屏之间检查中断信号。
+6. 每屏落盘原文 + URL + 时间 + 截图(截图只落本地),并让账本可回指。
+
+## 禁止
+
+1. 禁止无证据推断:没抓到的内容不能凭印象补写。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;浏览器采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止在没有 `--consent ` 时继续,禁止超出白名单页面,禁止写入型操作(发消息、点赞、改设置、下载附件)。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 采了多少屏、是否触达上限、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Define the consent flow, read-only whitelist, resource ceilings, per-screen persistence, and interruption rules for browser computer use. Never act without consent: when `--consent ` is missing the command ends with `exit 2` and its receipt says it is waiting for user consent.
+
+### 1. Ask before acting (consent protocol)
+
+Before touching anything, explain and wait for explicit consent:
+
+1. **What will be visited**: specific sites and pages, listed one by one — not a vague "some platform".
+2. **What will be taken**: only public content on those pages; no private messages, no settings pages, no attachment downloads.
+3. **Resource ceiling**: default **≤20 screens / ≤10 minutes / ≤6 scrolls per minute**; anything more needs a stated reason and a fresh consent round.
+4. **What gets persisted**: every screen persists raw text + URL + timestamp + screenshot; screenshots stay local in `/tmp/dst-evidence//` and are never committed (contract §4: screenshots, receipts, and diff images are not committed).
+5. **How to stop**: the user may stop at any time; check the interrupt signal between every scroll and every screen.
+
+After consent:
+
+- Record the grant with `distilly consent grant` (stored in `~/.distilly/consent.json`).
+- Collect with `distilly collect x --mode browser --consent ` (pick the channel from the contract table).
+- The user can inspect it with `distilly consent list` and withdraw it with `distilly consent revoke`.
+
+**No token means exit**: never reuse an old token, never switch to another scraping route, and never have the user hand-copy page content and pass it off as collected material.
+
+### 2. Read-only whitelist
+
+- Visit only the URLs the user listed in the consent; on a redirect to another site, a login wall, or a CAPTCHA, stop and ask.
+- Read only: no form submissions, no messages, no likes, no follows, no settings changes, no attachment downloads.
+- Collect only the page body and the public links on it; never guess at hidden endpoints.
+- Re-fetching the same page wastes budget: check the ledger for that URL first.
+
+### 3. Per-screen persistence
+
+Every screen (the visible region after each scroll) persists four things:
+
+| Artifact | Location |
+|----------|----------|
+| Page text (normalized, with paragraph anchors `[k00NN]` / `[k00NN:tM]`) | `knowledge/text/.md` |
+| Raw bytes (when a page snapshot must be kept) | `knowledge/raw//...` (append-only) |
+| URL + fetch time + method | the ledger entry in `knowledge/index.json` (`origin`, `fetched_at`, `method`, `credentialed`, `warnings[]`) |
+| Screenshot | local `/tmp/dst-evidence//` (never committed) |
+
+After collection: run `distilly retrospect` first, then read `evidence/derived/*.json`, and re-check the computer-use share and anchor back-reference rate with `distilly doctor`. Text or screenshots added by the user later are registered with `distilly note --from ` or `distilly harvest`.
+
+### 4. Failure and interruption
+
+- The user says stop: stop immediately, persist what was collected per section 3 above, and report honestly "how many screens, and where it stopped".
+- Expired login / anti-bot challenge / CAPTCHA: stop and report; let the user decide whether to sign in again or switch to an export bundle.
+- The page does not match expectations: report the difference; never guess at content and never pad with generic links.
+- Ceiling reached (20 screens / 10 minutes / 6 scrolls per minute): stop and ask whether to extend; never raise it unilaterally.
+- Every failure goes into `warnings[]` / `unavailable[]`; nothing degrades silently.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`).
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Get explicit consent before acting and record it with `distilly consent grant`; execute with `--consent `.
+5. Respect the default ceiling of ≤20 screens / ≤10 minutes / ≤6 scrolls per minute, and check the interrupt signal between screens.
+6. Persist raw text + URL + timestamp + screenshot per screen (screenshots stay local) and keep the ledger able to back-reference them.
+
+### MUST NOT
+
+1. No evidence-free inference: content that was never captured is never reconstructed from memory.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; browser collection goes only through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never continue without `--consent `, never leave the whitelisted pages, and never perform write actions (messages, likes, settings changes, attachment downloads).
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- How many screens were captured, whether a ceiling was hit, which steps were skipped, and why.
diff --git a/prompts/correction_handler.md b/prompts/correction_handler.md
index b39c92a5..2d115ade 100644
--- a/prompts/correction_handler.md
+++ b/prompts/correction_handler.md
@@ -100,3 +100,132 @@
- 超出时,将语义相近的 correction 合并归纳为 1 条
- 合并时优先保留最新的表述
- 每次合并告知用户:"已将 {N} 条相似规则合并为 {M} 条"
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再判断纠正归属。
+2. 用户的纠正属于口述证据:标注"用户口述,无锚点";如果能对上原材料锚点,补 `文件 + 锚点`(例如 `knowledge/text/feishu.md [k0042]`)。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 先确认场景 / 错误行为 / 正确行为三要素;用户说得模糊只追问一次。
+5. 写入统一走 `distilly skill update`(写入前用 `distilly skill version` 存档),不手改 `work.md` / `persona.md` / `SKILL.md` / `meta.json`。
+6. Persona correction 超过 50 条时先合并,并把合并结果告诉用户。
+
+## 禁止
+
+1. 禁止无证据推断:不要把用户的一句纠正扩展成一整套新人格。
+2. 禁止改写用户原话;用户怎么说的就怎么写进 correction。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当作已确认的纠正。
+6. 禁止直接手改最终产物文件。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些纠正未决、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Identify the user's correction intent and route it to exactly one of two outputs: a Work patch (replaceable `##` sections for `work.md`) or a Persona correction record (`{scene, wrong, correct}`).
+
+### Output contract (abstract)
+
+- Trigger phrases → extract scene / wrong behavior / correct behavior → decide Work vs Persona → emit the patch or the correction JSON → check conflicts → confirm and apply. Persona corrections cap at 50 per file and are merged when exceeded.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then decide where the correction belongs.
+2. A user correction is spoken evidence: label it "user statement, no anchor"; when it maps onto source material, add `file + anchor` (e.g. `knowledge/text/feishu.md [k0042]`).
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Confirm scene / wrong behavior / correct behavior first; when the user is vague, ask exactly once.
+5. Apply everything through `distilly skill update` (archive with `distilly skill version` first); never hand-edit `work.md`, `persona.md`, `SKILL.md`, or `meta.json`.
+6. When persona corrections exceed 50, merge first and tell the user what was merged.
+
+### MUST NOT
+
+1. No evidence-free inference: one correction never expands into a whole new personality.
+2. Never rewrite the user's own words; the correction records exactly what they said.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never treat a candidate as a confirmed correction.
+6. Never directly hand-edit the final artifacts.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which corrections are unresolved, which steps were skipped, and why.
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再判断纠正归属。
+2. 用户的纠正属于口述证据:标注"用户口述,无锚点";如果能对上原材料锚点,补 `文件 + 锚点`(例如 `knowledge/text/feishu.md [k0042]`)。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 先确认场景 / 错误行为 / 正确行为三要素;用户说得模糊只追问一次。
+5. 写入统一走 `distilly skill update`(写入前用 `distilly skill version` 存档),不手改 `work.md` / `persona.md` / `SKILL.md` / `meta.json`。
+6. Persona correction 超过 50 条时先合并,并把合并结果告诉用户。
+
+## 禁止
+
+1. 禁止无证据推断:不要把用户的一句纠正扩展成一整套新人格。
+2. 禁止改写用户原话;用户怎么说的就怎么写进 correction。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当作已确认的纠正。
+6. 禁止直接手改最终产物文件。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些纠正未决、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Identify the user's correction intent and route it to exactly one of two outputs: a Work patch (replaceable `##` sections for `work.md`) or a Persona correction record (`{scene, wrong, correct}`).
+
+### Output contract (abstract)
+
+- Trigger phrases → extract scene / wrong behavior / correct behavior → decide Work vs Persona → emit the patch or the correction JSON → check conflicts → confirm and apply. Persona corrections cap at 50 per file and are merged when exceeded.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then decide where the correction belongs.
+2. A user correction is spoken evidence: label it "user statement, no anchor"; when it maps onto source material, add `file + anchor` (e.g. `knowledge/text/feishu.md [k0042]`).
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Confirm scene / wrong behavior / correct behavior first; when the user is vague, ask exactly once.
+5. Apply everything through `distilly skill update` (archive with `distilly skill version` first); never hand-edit `work.md`, `persona.md`, `SKILL.md`, or `meta.json`.
+6. When persona corrections exceed 50, merge first and tell the user what was merged.
+
+### MUST NOT
+
+1. No evidence-free inference: one correction never expands into a whole new personality.
+2. Never rewrite the user's own words; the correction records exactly what they said.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never treat a candidate as a confirmed correction.
+6. Never directly hand-edit the final artifacts.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which corrections are unresolved, which steps were skipped, and why.
+
diff --git a/prompts/intake.md b/prompts/intake.md
index 9326c378..eaee7aea 100644
--- a/prompts/intake.md
+++ b/prompts/intake.md
@@ -133,3 +133,68 @@
```
用户确认后进入 Step 2 文件导入。
+
+---
+
+## 必须
+
+1. 先把用户的原话按字段拆开列出来,再问下一题;不要替用户补字段。
+2. 缺失字段留空并显式写 `unknown`,不得根据公司/职级推断性别、MBTI、性格。
+3. 汇总确认里每个字段都要能指回用户的原话;用户说"跳过"就真的留空。
+4. `celebrity` 的第 4 个问题必须确认 `research_profile`(默认 `budget-friendly`),不得默认跳过。
+5. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;intake 阶段不引用派生结论。
+6. 确认前不得开始采集;确认后进入 Collect(`distilly harvest` / `distilly collect`)。
+
+## 禁止
+
+1. 禁止无证据推断:用户没说的性格、职级、公司一律不写。
+2. 禁止改写用户原话(尤其是主观印象字段,必须原样保留)。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;需要凭据的渠道只走 `distilly collect`。
+5. 禁止把候选(candidate)标签当作用户确认过的信息。
+6. 禁止一次抛出多个问题;一次只问一个。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点(intake 阶段通常为 0,如实写 0)。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`);intake 不写文件时写"无"。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些字段没问到、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Collect the minimum manual profile for a new Skill: 3 questions for `colleague` and `relationship`, 4 for `celebrity` (the fourth confirms `research_profile`). Everything except the alias may be skipped.
+
+### Output contract (abstract)
+
+- Q1 alias/codename → slug (lowercase, hyphen-joined), Q2 one-line basic info (company, level, role, gender), Q3 one-line personality profile (MBTI, zodiac, tags, culture, impression) parsed into fixed field lists, plus a confirmation summary before collection starts.
+
+### MUST
+
+1. List the user's own words split by field before asking the next question; never fill a field for them.
+2. Missing fields stay empty and are explicitly `unknown`; never infer gender, MBTI, or personality from company or level.
+3. Every field in the summary must trace back to the user's own words; when the user says "skip", it really stays empty.
+4. For `celebrity`, the fourth question must confirm `research_profile` (default `budget-friendly`); never skip it silently.
+5. Run `distilly retrospect` first, then read `evidence/derived/*`; intake itself never cites derived conclusions.
+6. Do not start collection before confirmation; after confirmation move to Collect (`distilly harvest` / `distilly collect`).
+
+### MUST NOT
+
+1. No evidence-free inference: personality, level, or company that the user never stated is never written down.
+2. Never rewrite the user's own words, especially the free-form impression field, which stays verbatim.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; channels that need credentials go through `distilly collect`.
+5. Never treat a candidate tag as information the user confirmed.
+6. Never ask several questions at once; ask one at a time.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors (usually 0 during intake — say 0 honestly).
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`); write "none" when intake writes no file.
+- Which channels were unavailable (`unavailable[]`).
+- Which fields were never asked, which steps were skipped, and why.
+
diff --git a/prompts/merger.md b/prompts/merger.md
index a63c25e7..11185ec8 100644
--- a/prompts/merger.md
+++ b/prompts/merger.md
@@ -89,3 +89,68 @@
版本将从 {vN} 升级到 {vN+1}。
确认应用更新?
```
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再判断增量归属。
+2. 每条新增/冲突信息跟 `文件 + 锚点`,例如 `knowledge/text/messages.md [k0017:t3]`;旧结论保留原锚点。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 只追加增量、不覆盖已有结论;冲突必须输出冲突提示,等用户决定。
+5. 更新走 `distilly skill update`(写入前先用 `distilly skill version` 存档),不手改最终文件。
+6. 版本号只在用户确认后升级,并在摘要里写明从哪个版本到哪个版本。
+
+## 禁止
+
+1. 禁止无证据推断:新文件里没写的行为不能"顺便"补进 persona。
+2. 禁止改写引文;新增的口头禅/原话必须逐字保留并附锚点。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当作已确认的增量。
+6. 禁止静默覆盖或静默删除旧结论。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些冲突未决、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Given the existing `work.md` and `persona.md` plus new material, decide which part each new item belongs to and emit an incremental patch. Principle: append only, never overwrite an existing conclusion; on conflict, surface the conflict and let the user decide.
+
+### Output contract (abstract)
+
+- Step 1 classify each item (work vs persona), Step 2 check for conflicts, Step 3 emit the `work.md` / `persona.md` patches, Step 4 show an update summary with the version bump.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then classify the delta.
+2. Every new or conflicting item carries `file + anchor`, e.g. `knowledge/text/messages.md [k0017:t3]`; existing conclusions keep their original anchors.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Append delta only and never overwrite an existing conclusion; conflicts produce a conflict notice and wait for the user.
+5. Apply updates with `distilly skill update` (archive with `distilly skill version` first); never hand-edit the final files.
+6. Bump the version only after the user confirms, and state the from-version and to-version in the summary.
+
+### MUST NOT
+
+1. No evidence-free inference: behavior absent from the new material is never slipped into the persona.
+2. Never rewrite quotations; new catchphrases and verbatim lines keep their exact wording and carry an anchor.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never treat a candidate as a confirmed delta.
+6. Never silently overwrite or silently delete an existing conclusion.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which conflicts are unresolved, which steps were skipped, and why.
+
diff --git a/prompts/persona_analyzer.md b/prompts/persona_analyzer.md
index 3441399a..9b9cef57 100644
--- a/prompts/persona_analyzer.md
+++ b/prompts/persona_analyzer.md
@@ -131,3 +131,74 @@ emoji:[无/偶尔/频繁,类型]
- 原材料不足的维度:标注 `(原材料不足)`
- 有原文依据的结论:引用原话(加引号)
- 手动标签与文件分析冲突时:输出两个版本并注明,供 persona_builder 处理
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每个维度(表达风格 / 决策模式 / 人际行为 / 边界与雷区)的每条结论都要跟 `文件 + 锚点`,例如 `knowledge/text/feishu.md [k0042]`、`knowledge/text/email.md [k0017:t3]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 标签翻译必须与用户手动填写的标签一一对应,不得自行新增标签。
+5. 没有证据的维度写 `unknown`,并说明补什么材料能填上。
+6. 手动标签与文件分析冲突时,输出两个版本并注明来源,交给 persona_builder 决定。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、MBTI 通识都不能当原材料结论。
+2. 禁止改写引文;引用原话必须逐字保留并附锚点。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)写成结论,尤其是只有一两条锚点的模式。
+6. 禁止用泛化形容词代替可执行的行为规则。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些维度证据不足、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Extract **{name}**'s personality traits and behavior patterns from the user's manual tags plus the imported source material, so that `persona_builder.md` can turn them into `persona.md`.
+
+Priority rule: manual tags > file analysis. Conflicts are reported as two labeled versions.
+
+### Output contract (abstract)
+
+- Expression style: catchphrases, high-frequency words, jargon, sentence shape, emoji/punctuation, formality 1-5.
+- Decision pattern: priorities, what makes them push, what makes them stall, how they disagree, how they answer challenges.
+- Interpersonal behavior: toward managers, reports, peers, and under pressure.
+- Boundaries: what they resist, where they draw lines, how they refuse.
+- Every dimension is either anchored or explicitly `unknown`.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion in every dimension (expression / decisions / interpersonal / boundaries) carries `file + anchor`, e.g. `knowledge/text/feishu.md [k0042]` or `knowledge/text/email.md [k0017:t3]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Tag translation maps one-to-one onto the tags the user actually supplied; never invent new tags.
+5. Dimensions without evidence are written as `unknown`, together with what material would fill them.
+6. When manual tags and file analysis conflict, output both versions with their sources and let `persona_builder.md` decide.
+
+### MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and generic MBTI lore are not source conclusions.
+2. Never rewrite quotations; verbatim quotes keep their exact wording and carry an anchor.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion, especially a pattern backed by only one or two anchors.
+6. Never replace an executable behavior rule with a generic adjective.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which dimensions are thin, which steps were skipped, and why.
+
diff --git a/prompts/persona_builder.md b/prompts/persona_builder.md
index 57540dd1..e121baf4 100644
--- a/prompts/persona_builder.md
+++ b/prompts/persona_builder.md
@@ -171,3 +171,69 @@
```
(原材料不足,以下内容基于 {标签名} 标签推断,建议追加聊天记录验证)
```
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写 persona。
+2. Layer 0 的每条规则都必须写成"在什么情况下 → 做什么";示例话术必须来自锚点原文,或显式标注为候选。
+3. 每条结论跟 `文件 + 锚点`,例如 `knowledge/text/feishu.md [k0042]`、`knowledge/text/messages.md [k0017:t3]`。
+4. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理,不直接写进 Layer 0。
+5. 少于 2 条原材料支撑的层,必须保留 `(原材料不足…)` 占位,不得用想象补满。
+6. Layer 1 的身份字段只能来自用户手动信息;缺失就省略,不猜。
+
+## 禁止
+
+1. 禁止无证据推断:标签不能自行扩展成新的性格设定。
+2. 禁止改写引文;口头禅、示例话术必须逐字保留并附锚点。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)写成确定行为规则。
+6. 禁止写形容词式的空规则(如"你很强势"),必须落到具体动作。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些层证据不足、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Turn the `persona_analyzer.md` output plus the user's manual tags into the `persona.md` body. The file defines the person's character, communication style, and behavior patterns, and it must read like the person actually talking.
+
+### Output contract (abstract)
+
+- Layer 0 core character: user tags translated into concrete behavior rules.
+- Layer 1 identity, Layer 2 expression style with realistic example lines, Layer 3 decisions and judgment, Layer 4 interpersonal behavior, Layer 5 boundaries, a correction log, and the global behavior principles.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write the persona.
+2. Every Layer 0 rule is written as "in situation X → do Y"; example lines come from anchored source text or are explicitly labeled candidates.
+3. Every conclusion carries `file + anchor`, e.g. `knowledge/text/feishu.md [k0042]` or `knowledge/text/messages.md [k0017:t3]`.
+4. Run `distilly retrospect` first, then read `evidence/derived/*`; derived patterns stay candidates and never go straight into Layer 0.
+5. Any layer backed by fewer than 2 source items keeps the `(insufficient source material …)` placeholder instead of being filled with invention.
+6. Layer 1 identity fields come only from the user's manual input; drop what is missing, never guess.
+
+### MUST NOT
+
+1. No evidence-free inference: a tag never grows into a brand-new character setting on its own.
+2. Never rewrite quotations; catchphrases and example lines keep their exact wording and carry an anchor.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a settled behavior rule.
+6. Never ship adjective-only rules such as "you are assertive"; every rule resolves to a concrete action.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which layers are thin, which steps were skipped, and why.
+
diff --git a/prompts/relationship/intake.md b/prompts/relationship/intake.md
index 76a568cc..57293cd6 100644
--- a/prompts/relationship/intake.md
+++ b/prompts/relationship/intake.md
@@ -1,3 +1,37 @@
+# 关系 Intake Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+关系类 Skill 的 intake:只问 3 个问题(称呼/代号、关系与最深的记忆、可用材料),汇总确认后进入 Collect;不使用职场框架。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 只问 3 个问题,一次一个;缺失字段留空并写 `unknown`。
+5. 汇总确认里每个字段都要能指回用户的原话。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止把职场框架(职级、CR、OKR 之类)套到关系场景。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Relationship Intake Prompt
## Goal
@@ -107,3 +141,29 @@ Summary:
Confirm? (confirm / edit [field])
```
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Ask only 3 questions, one at a time; missing fields stay empty and are `unknown`.
+5. Every field in the confirmation summary traces back to the user's own words.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never apply workplace framing (levels, code review, OKRs) to a relationship scenario.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/relationship/merger.md b/prompts/relationship/merger.md
index 3b57bdf6..c5158818 100644
--- a/prompts/relationship/merger.md
+++ b/prompts/relationship/merger.md
@@ -1,3 +1,37 @@
+# 关系增量合并 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+把新材料并入关系 persona:只追加不覆盖,冲突输出冲突提示交由用户决定;只有在关系 Skill 确实含任务分支、且新材料是执行方法时才更新 `work.md`。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每条新增或冲突信息跟锚点;旧结论保留原锚点。
+5. 更新走 `distilly skill update`(先 `distilly skill version` 存档),不手改最终文件。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止静默覆盖已有结论或静默删除关系细节。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Relationship Merger
## Task
@@ -48,3 +82,29 @@ skill without flattening the existing emotional pattern.
- updated {n} expression details
- preserved {n} contradictions
```
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every new or conflicting item carries an anchor; existing conclusions keep their original anchors.
+5. Apply updates with `distilly skill update` (archive with `distilly skill version` first); never hand-edit the final files.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never silently overwrite an existing conclusion or delete relationship detail.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/relationship/persona_analyzer.md b/prompts/relationship/persona_analyzer.md
index ffa1723b..8791ea6f 100644
--- a/prompts/relationship/persona_analyzer.md
+++ b/prompts/relationship/persona_analyzer.md
@@ -1,3 +1,37 @@
+# 关系 Persona 分析 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+从聊天、信件与记忆描述中提取关系模式:称呼与语言习惯、相处节奏、冲突与和解、距离与沉默、情绪触发点;证据与推断必须分开。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每个关系维度(相处模式 / 语言习惯 / 冲突场景 / 重逢场景 / 距离与沉默)单独给锚点或写 `unknown`。
+5. 证据与推断分开标注;样本薄的维度写 `(source material insufficient)`。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止把关系材料写成传记摘要,或用抽象形容词替代具体相处细节。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Relationship Persona Analyzer
## Task
@@ -106,3 +140,29 @@ Emotional afterimage: [...]
- Separate evidence from inference
- Mark thin areas as `(source material insufficient)`
- Prefer pattern extraction over biography summary
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every relationship dimension (interaction patterns / language habits / conflict scenes / reunion scenes / distance and silence) carries its own anchor or is `unknown`.
+5. Keep evidence and inference separately labeled; thin dimensions are marked `(source material insufficient)`.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never turn relationship material into a biography summary or replace concrete interaction detail with abstract adjectives.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/relationship/persona_builder.md b/prompts/relationship/persona_builder.md
index 8d69fa50..ec6ff759 100644
--- a/prompts/relationship/persona_builder.md
+++ b/prompts/relationship/persona_builder.md
@@ -1,3 +1,37 @@
+# 关系 Persona 生成 Prompt(中文要点)
+
+> 英文正文见下方 `## English` 段。两段的命令引用必须一致,锚点格式统一为 `[k00NN]` / `[k00NN:tM]`。
+
+生成关系类 persona:把记忆模式写成具体的关系行为(日常、冲突、重逢、沉默),保留情绪真实感,不使用职场框架。
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 每个关系场景至少一条具体行为或对话示例,并跟锚点或标注为候选。
+5. 保留矛盾与不对称(谁更主动、谁先沉默),不要压平。
+
+## 禁止
+
+1. 禁止无证据推断:印象、常识、模型记忆都不能当结论。
+2. 禁止改写引文;长段原文、完整 transcript、完整字幕一律不得进仓库。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止用职场框架或抽象形容词替代具体的关系行为。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执、`knowledge/index.json` 或 `evidence/renders/receipt.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些步骤没跑、为什么。
+
+---
+
+## English
+
# Relationship Persona Builder
## Task
@@ -120,3 +154,29 @@ What defined this connection:
- Keep it emotionally concrete
- Avoid workplace framing
- Prefer remembered patterns over abstract adjectives
+
+---
+
+## MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Every relationship scene has at least one concrete behavior or dialogue example, anchored or explicitly labeled a candidate.
+5. Preserve contradictions and asymmetry (who reaches out, who goes quiet) instead of flattening them.
+
+## MUST NOT
+
+1. No evidence-free inference: impressions, common sense, and model memory are not conclusions.
+2. Never rewrite quotations; long passages, full transcripts, and full subtitles never enter the repository.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never substitute workplace framing or abstract adjectives for concrete relationship behavior.
+
+## RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt, `knowledge/index.json`, or `evidence/renders/receipt.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which steps were skipped, and why.
diff --git a/prompts/retrospection.md b/prompts/retrospection.md
new file mode 100644
index 00000000..b048d9f4
--- /dev/null
+++ b/prompts/retrospection.md
@@ -0,0 +1,169 @@
+# 证据阅读 Prompt(怎么读 knowledge/text 与 evidence/derived)
+
+## 任务
+
+规定 LLM 在读 `knowledge/text/*` 与 `evidence/derived/*` 时的读取顺序、事实/候选的区分、锚点引用格式,以及样本不足时的表达方式。目标是让每条结论都能被第三方按锚点复核。
+
+---
+
+## 1. 读取顺序
+
+1. `knowledge/index.json`(账本):先看有哪些来源、各自 `sha256`、`bytes`、`method`、`credentialed`、`warnings[]`。账本里没有的东西不算来源。
+2. `knowledge/text/.md`(归一化正文):逐段读,记下段落锚点。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*.json`(派生结论、每条带 evidence 锚点)。顺序不能反:先读派生文件再补跑命令,会让结论失去锚点。
+4. 需要体检时用 `distilly doctor`(证据覆盖率 / 不可用渠道 / 锚点回指率 / computer-use 占比)。
+5. 用户后来补的文字/截图,先 `distilly note --from ` 登记(`method:"model-read"`)或 `distilly harvest` 入库,再引用。
+
+---
+
+## 2. 事实与候选
+
+- **事实(fact)**:`knowledge/text/**` 里能指到具体锚点、且不改写原文的陈述。写法:`结论(knowledge/text/feishu.md [k0042])`。
+- **候选(candidate)**:派生文件里的模式、倾向、统计、跨语境推断;只有一两个锚点支撑的观察;用户没确认的标签。写法:`候选:……(锚点 [k0042]、[k0051],样本 2 条)`。
+- 候选永远不能升级为结论:可以写进分析草稿,但必须保留"候选"字样,并写清缺什么证据才能升级。
+- 派生 JSON 里不存在的字段一律不得臆造(例如不要发明 `confidence`、`score` 之类的键)。只读文件里真实出现的字段;字段含义不明就写 `unknown` 并说明。
+- 事实之间冲突时保留两条并标注冲突,不要私自选一个。
+
+---
+
+## 3. 锚点引用格式
+
+- 段落锚点:`[k00NN]`(4 位补零,如 `[k0012]`)。
+- 带轮次的锚点:`[k00NN:tM]`(如 `[k0012:t3]`,用于对话/多轮消息)。
+- 引用时写 `文件 + 锚点`,例如:`knowledge/text/feishu.md [k0042]`、`knowledge/text/email.md [k0017:t3]`。
+- 一条结论由多处支撑时列多个锚点,不要只挑一个:
+ `……(knowledge/text/docs.md [k0008];knowledge/text/messages.md [k0031:t2])`。
+- 引用的锚点必须能在 `knowledge/index.json` 回指;回指不上的锚点视为无效,必须删掉或改用有效锚点。
+- 不允许自造锚点、不允许改锚点编号、不允许把平台首页/搜索页当成来源。
+
+---
+
+## 4. 样本不足时怎么写
+
+- 没有证据:写 `unknown`,后面跟一句"缺什么材料可以补上"。
+- 样本很少(1–2 条锚点):写 `候选:……(样本 N 条,锚点 …)`,并说明它只在哪个语境出现。
+- 某个维度整体薄:写 `(原材料不足)`,列出建议追加的材料类型(聊天记录 / 邮件 / 文档 / 长访谈)。
+- celebrity 场景:总来源 < 10 条时按冷门人物协议处理——心智模型限制为 2–3 个,薄弱模型标"基于有限信息",扩大诚实边界章节。
+- 不要把"样本不足"写成"倾向于":模糊表述比 `unknown` 更糟。
+
+---
+
+## 5. 汇报复述模板
+
+开始写结论前,先给用户一段复述:
+
+```
+读了什么:
+- knowledge/index.json:N 条来源(kind 分布:…),不可用渠道:…
+- knowledge/text/*.md:F 个文件,共 R 段,A 个锚点
+- evidence/derived/*.json:D 个文件(每条结论带锚点)
+
+接下来写结论时:每条结论带 文件 + 锚点;无证据写 unknown;候选标注 candidate。
+```
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条结论跟 `文件 + 锚点`(`[k00NN]` 或 `[k00NN:tM]`),例如 `knowledge/text/feishu.md [k0042]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 区分事实与候选,候选保留"候选"字样并写明升级所需证据。
+5. 没有证据的结论写 `unknown`,并说明补什么材料。
+6. 引用的锚点必须能在 `knowledge/index.json` 回指;发现坏锚点先停下报告。
+
+## 禁止
+
+1. 禁止无证据推断:常识、印象、模型记忆都不能当结论。
+2. 禁止改写引文;引用原话必须逐字保留并附锚点。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;需要补来源时走 `distilly note --from ` 或 `distilly harvest`。
+5. 禁止把候选(candidate)当结论,也禁止臆造派生 JSON 字段。
+6. 禁止用"倾向于""可能"这类模糊表述掩盖样本不足。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些维度样本不足、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Define how the model reads `knowledge/text/*` and `evidence/derived/*`: read order, the fact/candidate split, anchor citation format, and how to phrase thin samples. The goal is that any third party can re-check every conclusion through its anchors.
+
+### 1. Read order
+
+1. `knowledge/index.json` (the ledger): first see which sources exist, with their `sha256`, `bytes`, `method`, `credentialed`, `warnings[]`. Anything without a ledger entry is not a source.
+2. `knowledge/text/.md` (normalized text): read paragraph by paragraph and note the paragraph anchors.
+3. Run `distilly retrospect` first, then read `evidence/derived/*.json` (derived conclusions, each with evidence anchors). Never invert the order: reading derived files before re-running the command leaves conclusions without anchors.
+4. For a health check use `distilly doctor` (evidence coverage / unavailable channels / anchor back-reference rate / computer-use share).
+5. Text or screenshots the user adds later are registered with `distilly note --from ` (`method:"model-read"`) or `distilly harvest` before being cited.
+
+### 2. Facts and candidates
+
+- **Fact**: a statement that points at a specific anchor in `knowledge/text/**` without rewriting the source. Form: `conclusion (knowledge/text/feishu.md [k0042])`.
+- **Candidate**: patterns, tendencies, statistics, and cross-context inferences from derived files; observations backed by only one or two anchors; tags the user never confirmed. Form: `candidate: … (anchors [k0042], [k0051], sample 2)`.
+- A candidate never becomes a conclusion: it may appear in a draft, but the word "candidate" stays, together with what evidence would promote it.
+- Never invent fields that do not exist in the derived JSON (no made-up `confidence` or `score` keys). Read only fields that are really there; when a field is unclear, write `unknown` and say so.
+- When two facts conflict, keep both and mark the conflict instead of silently picking one.
+
+### 3. Anchor citation format
+
+- Paragraph anchor: `[k00NN]` (4-digit zero-padded, e.g. `[k0012]`).
+- Turn-aware anchor: `[k00NN:tM]` (e.g. `[k0012:t3]`, for conversations and multi-turn messages).
+- Cite `file + anchor`, e.g. `knowledge/text/feishu.md [k0042]`, `knowledge/text/email.md [k0017:t3]`.
+- When several places support one conclusion, list several anchors instead of cherry-picking one:
+ `… (knowledge/text/docs.md [k0008]; knowledge/text/messages.md [k0031:t2])`.
+- Every cited anchor must back-reference into `knowledge/index.json`; an anchor that does not resolve is invalid and must be dropped or replaced.
+- Never invent anchors, renumber anchors, or treat platform roots and search pages as sources.
+
+### 4. How to phrase thin samples
+
+- No evidence: write `unknown`, followed by what material would supply it.
+- Very few samples (1–2 anchors): write `candidate: … (sample N, anchors …)` and say which context it appears in.
+- A whole dimension is thin: write `(insufficient source material)` and list the material types worth adding (chat logs / email / documents / long interviews).
+- Celebrity case: below 10 total sources apply the cold-figure protocol — limit mental models to 2–3, mark thin models "based on limited information", and expand the honest boundaries section.
+- Never turn "thin sample" into "tends to": vague wording is worse than `unknown`.
+
+### 5. Restatement template
+
+Before writing conclusions, restate to the user:
+
+```
+What was read:
+- knowledge/index.json: N sources (kind breakdown: …); unavailable channels: …
+- knowledge/text/*.md: F files, R rows, A anchors
+- evidence/derived/*.json: D files (every conclusion carries anchors)
+
+When writing conclusions: every conclusion carries file + anchor; no evidence means unknown; candidates stay labeled candidate.
+```
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every conclusion carries `file + anchor` (`[k00NN]` or `[k00NN:tM]`), e.g. `knowledge/text/feishu.md [k0042]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Keep facts and candidates apart; a candidate keeps the word "candidate" and states the evidence that would promote it.
+5. Conclusions without evidence are written as `unknown`, together with the material that would fill them.
+6. Every cited anchor must back-reference into `knowledge/index.json`; on a broken anchor, stop and report it.
+
+### MUST NOT
+
+1. No evidence-free inference: common sense, impressions, and model memory are not conclusions.
+2. Never rewrite quotations; verbatim quotes keep their exact wording and carry an anchor.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; to add a source use `distilly note --from ` or `distilly harvest`.
+5. Never present a candidate as a conclusion, and never invent derived JSON fields.
+6. Never hide a thin sample behind vague wording such as "tends to" or "may".
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which dimensions are thin, which steps were skipped, and why.
diff --git a/prompts/work_analyzer.md b/prompts/work_analyzer.md
index dd963f01..1634def1 100644
--- a/prompts/work_analyzer.md
+++ b/prompts/work_analyzer.md
@@ -179,3 +179,74 @@
- 没有信息的维度:标注 `(原材料不足,建议追加相关文档)`
- 有原文依据的结论:加引号标注原话
- 输出结果直接用于生成 work.md,要求具体可执行,不要写"可能""倾向于"这类模糊表述
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写结论。
+2. 每条技术结论跟 `文件 + 锚点`,例如 `knowledge/text/docs.md [k0008]`;引用原话时逐字保留。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 只提取工作相关内容,闲聊、情绪、私人话题一律不进 Work Skill。
+5. 只按 {name} 的实际职位使用对应专项维度,不要把所有职位模板都套一遍。
+6. 没有信息的维度写 `unknown`,并写明建议追加哪类文档。
+
+## 禁止
+
+1. 禁止无证据推断:职位、公司、级别不能推出技术规范。
+2. 禁止改写引文;禁止把总结写成引号内的"原话"。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)当结论。
+6. 禁止用"可能使用""倾向于"这类模糊表述顶替具体规范。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些维度证据不足、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Extract **{name}**'s working methods and technical standards from the imported source material so that `work_builder.md` can produce `work.md`.
+
+Principle: work content only, ignore small talk, never infer — write only what is grounded, otherwise mark it as missing.
+
+### Output contract (abstract)
+
+- Responsibility scope: systems, modules, business lines, maintained documents, boundaries.
+- Workflow: how they take a task, structure a design doc, manage deadlines, handle incidents.
+- Output preferences: tables vs lists vs prose, conclusion-first or build-up, level of detail.
+- Experience base: explicit judgments, pitfalls, technical opinions, quoted verbatim.
+- Role-specific dimensions (backend / frontend / ML / PM / design / data) — only the ones matching the actual role.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write conclusions.
+2. Every technical conclusion carries `file + anchor`, e.g. `knowledge/text/docs.md [k0008]`; verbatim quotes keep their exact wording.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Extract work content only; chatter, emotions, and private topics never enter the Work Skill.
+5. Use only the role-specific dimension block that matches {name}'s actual role; never run every template.
+6. Dimensions without information are written as `unknown`, together with the kind of document that would fill them.
+
+### MUST NOT
+
+1. No evidence-free inference: a job title, company, or level never implies a technical standard.
+2. Never rewrite quotations, and never present your own summary as a quoted "verbatim" line.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a conclusion.
+6. Never let "may use" or "tends to" stand in for a concrete standard.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which dimensions are thin, which steps were skipped, and why.
+
diff --git a/prompts/work_builder.md b/prompts/work_builder.md
index f4074ff5..6068c155 100644
--- a/prompts/work_builder.md
+++ b/prompts/work_builder.md
@@ -99,3 +99,68 @@
2. 知识结论要具体,避免泛泛而谈(错误示例:"注重代码质量";正确示例:"函数单一职责,超过 50 行必须拆分")
3. 技术栈和规范要直接可执行,不要写成"可能使用"或"倾向于"
4. 整个文件用 Markdown 格式,标题层级清晰
+
+---
+
+## 必须
+
+1. 先列"读了哪些文件、各多少条、多少锚点",再写 work.md。
+2. 模板里的每一节都要落到具体规范;每条规范跟 `文件 + 锚点`,例如 `knowledge/text/docs.md [k0008]`。
+3. 先跑 `distilly retrospect`,再读 `evidence/derived/*`;派生结论按候选处理。
+4. 信息不足的维度保留占位符 `(暂无足够信息,建议追加相关文档)`,不要编。
+5. 经验知识库每条必须是可执行结论,引用原话时逐字保留并附锚点。
+6. Work 内容只来自 work_analyzer 的结果;需要改写结构时向用户说明改了什么。
+
+## 禁止
+
+1. 禁止无证据推断:职位名称推不出技术栈。
+2. 禁止改写引文或把总结包装成引号内的"原话"。
+3. 禁止把 key 写进对话或文件;凭据只从 `~/.distilly/*_config.json` 或环境变量读取。
+4. 禁止自己拼 API 请求;网络采集只走 `distilly collect`。
+5. 禁止把候选(candidate)写成规范。
+6. 禁止用"可能使用""倾向于"顶替具体规范。
+
+## 回执
+
+- 读过哪些文件、各多少条、多少锚点。
+- 生成/更新了哪些文件,各自 sha256(来自 `distilly` 的 `--json` 回执或 `knowledge/index.json`)。
+- 哪些渠道不可用(`unavailable[]`)。
+- 哪些章节证据不足、哪些步骤没跑、为什么。
+
+---
+
+## English
+
+### Task
+
+Turn the `work_analyzer.md` output into the `work.md` body: Part A of the generated Skill, so an agent can do real work the way this person does it.
+
+### Output contract (abstract)
+
+- Responsibility scope, technical standards (stack, code style, naming, API design, CR focus), workflow (requirements, design docs, incidents, code review), output style, experience base, and the "how to use this work skill" section.
+
+### MUST
+
+1. First list which files were read, how many rows each, and how many anchors; only then write work.md.
+2. Every template section resolves to a concrete standard; every standard carries `file + anchor`, e.g. `knowledge/text/docs.md [k0008]`.
+3. Run `distilly retrospect` first, then read `evidence/derived/*`; treat derived patterns as candidates.
+4. Sections without information keep the `(not enough information yet, add related documents)` placeholder; never invent content.
+5. Every experience-base line is an executable conclusion; verbatim quotes keep their exact wording and carry an anchor.
+6. Work content comes only from the `work_analyzer.md` result; when you restructure it, tell the user what changed.
+
+### MUST NOT
+
+1. No evidence-free inference: a job title never implies a tech stack.
+2. Never rewrite quotations, and never dress a summary up as a quoted "verbatim" line.
+3. Never write credentials into chat or files; they are read only from `~/.distilly/*_config.json` or environment variables.
+4. Never hand-craft API calls; all network collection goes through `distilly collect`.
+5. Never present a candidate as a standard.
+6. Never let "may use" or "tends to" replace a concrete standard.
+
+### RECEIPT
+
+- Which files were read, how many rows each, how many anchors.
+- Which files were created or updated, each with its sha256 (from the `distilly` `--json` receipt or `knowledge/index.json`).
+- Which channels were unavailable (`unavailable[]`).
+- Which sections are thin, which steps were skipped, and why.
+
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 19dea998..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-# Required
-requests>=2.28.0
-
-# Optional: Chinese name → slug conversion
-pypinyin>=0.48.0
-
-# Optional: Playwright for Lark-compatible browser login / DingTalk message scraping
-playwright>=1.40.0
-
-# Optional: Slack auto collector
-slack-sdk>=3.27.0
-
-# Optional: Word/Excel parsing (convert to PDF/CSV first if unavailable)
-python-docx>=1.1.0
-openpyxl>=3.1.0
diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs
new file mode 100644
index 00000000..f79f1d7a
--- /dev/null
+++ b/scripts/acceptance.mjs
@@ -0,0 +1,403 @@
+#!/usr/bin/env node
+/**
+ * 端到端验收(机械部分)。见 docs/v2/ACCEPTANCE.md §5。
+ *
+ * 断言:回执形状 / 幂等 / 确定性 / 锚点回指 / 字节守恒 / 单文件离线 / visual-check 八项。
+ * 任何一步的依赖命令还不存在时,**响亮失败**并指出缺哪个分支的产出,不静默跳过。
+ *
+ * usage:
+ * node scripts/acceptance.mjs [--corpus ] [--person ] [--keep] [--evidence ]
+ */
+import { spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+import { baselineSections, buildView } from './blind-test.mjs';
+import { REQUIRED_ARTIFACTS, inspectSkillArtifacts } from './skill-artifacts.mjs';
+
+const root = path.resolve(here, '..');
+const arg = (name, fallback) => {
+ const i = process.argv.indexOf(`--${name}`);
+ return i === -1 ? fallback : process.argv[i + 1];
+};
+const has = (name) => process.argv.includes(`--${name}`);
+
+const corpus = path.resolve(arg('corpus', path.join(root, 'tests/fixtures/public-corpus/synthetic-interview')));
+const person = arg('person', 'lin-gong');
+const keep = has('keep');
+const evidenceDir = path.resolve(arg('evidence', '/tmp/dst-evidence/pr-acceptance'));
+const results = [];
+let failed = 0;
+
+const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
+
+function record(name, ok, detail) {
+ results.push({ name, ok, detail });
+ if (!ok) failed += 1;
+ console.log(` ${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
+}
+
+function distilly(args, { allowMissing = false } = {}) {
+ const res = spawnSync('node', [path.join(root, 'bin/distilly.mjs'), ...args], {
+ cwd: workdir,
+ encoding: 'utf8',
+ env: { ...process.env, DISTILLY_HOME: path.join(workdir, '.distilly') },
+ });
+ if (res.status !== 0 && /unknown command|not implemented|Cannot find module/i.test(res.stderr || '')) {
+ if (allowMissing) return { missing: true, stderr: res.stderr };
+ throw new Error(`命令不可用:distilly ${args.join(' ')}\n${res.stderr?.slice(0, 400)}`);
+ }
+ return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
+}
+
+function parseReceipt(out) {
+ const start = out.indexOf('{');
+ if (start === -1) return null;
+ try {
+ return JSON.parse(out.slice(start));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * A mechanical Distill: the `work.md` + `persona.md` the acceptance run feeds to
+ * `skill create`.
+ *
+ * Acceptance cannot call a model, so the *content* is a fixture — but it is built
+ * from the corpus's own units, each quoted with its anchor, so the artifact cites
+ * real evidence instead of placeholders. The rows that use it check the part the
+ * mechanical layer owns:
+ *
+ * - `skill create` writes the documented artifact set;
+ * - the persona carries the Layer 0–5 structure `prompts/persona_builder.md` defines.
+ * Nothing checked this before, and the failure mode was real: a Skill whose
+ * Operating Rules promised "Layer 0 always wins" while its PART B contained no
+ * Layer 0 at all passed every gate, because nothing looked;
+ * - every anchor the artifact cites resolves in the ledger.
+ *
+ * Distillation *quality* is not this function's business — that is the effect layer
+ * (`scripts/blind-test.mjs`) and it needs a judge, not a fixture.
+ */
+function distillFixture({ units, displayName }) {
+ if (units.length === 0) throw new Error('交付物夹具需要至少一个锚点:knowledge/text 里没有段落锚点');
+ const at = (index) => units[index % units.length];
+ const line = (index) => {
+ const unit = at(index);
+ return `- ${unit.text.slice(0, 60)} [${unit.anchor}]`;
+ };
+ const short = (text) => (text.length > 24 ? `${text.slice(0, 24)}…` : text);
+
+ const work = [
+ '# Work(交付物夹具)',
+ '',
+ '> 由 scripts/acceptance.mjs 机械生成:每条都引用语料原文与锚点,用于验证 writer 与结构判据,不代表蒸馏质量。',
+ '',
+ '## 负责范围',
+ line(0),
+ line(1),
+ '',
+ '## 工作流程',
+ line(2),
+ line(3),
+ '',
+ '## 输出偏好',
+ line(4),
+ '',
+ '## 经验知识',
+ line(5),
+ line(6),
+ '',
+ ].join('\n');
+
+ const layer0 = ['## Layer 0:核心性格(最高优先级,任何情况下不得违背)'];
+ for (const index of [0, 1]) {
+ const unit = at(index);
+ layer0.push(`- 当讨论到「${short(unit.text)}」时 → 以原文为准,不改写、不补充 [${unit.anchor}]`);
+ }
+ layer0.push('');
+
+ const persona = [
+ `# ${displayName} — Persona(交付物夹具)`,
+ '',
+ ...layer0,
+ '## Layer 1:身份',
+ line(2),
+ '',
+ '## Layer 2:表达风格',
+ line(3),
+ line(4),
+ '',
+ '## Layer 3:决策与判断',
+ line(5),
+ '',
+ '## Layer 4:人际行为',
+ line(6),
+ '',
+ '## Layer 5:边界与雷区',
+ '(原材料不足,不推断)',
+ '',
+ ].join('\n');
+
+ return { work, persona };
+}
+
+/** The `[k00NN] text` units of a normalised body, in file order. */
+function unitsOf(body) {
+ const units = [];
+ for (const match of body.matchAll(/^\[(k\d{4}(?::t\d+)?)\]\s+(.*)$/gm)) {
+ units.push({ anchor: match[1], text: match[2].trim() });
+ }
+ return units;
+}
+
+const workdir = await mkdtemp(path.join(tmpdir(), 'dst-acceptance-'));
+await mkdir(evidenceDir, { recursive: true });
+
+try {
+ // 0. 准备一个 person 目录,把语料放进去
+ const personDir = path.join(workdir, 'skills', 'colleague', person);
+ await mkdir(personDir, { recursive: true });
+ await cp(corpus, path.join(workdir, 'corpus'), { recursive: true });
+
+ console.log(`验收语料:${path.relative(root, corpus)} 工作目录:${workdir}`);
+
+ // 1. harvest(幂等)
+ const h1 = distilly(['harvest', path.join(workdir, 'corpus'), '--person', person, '--json']);
+ const r1 = parseReceipt(h1.stdout);
+ record('harvest 退出码 0 且回执可解析', h1.status === 0 && !!r1, r1 ? `${r1.outputs?.length ?? 0} 个产物` : '无回执');
+ record('回执形状(command/ok/inputs/outputs/sha256)',
+ !!r1 && typeof r1.command === 'string' && typeof r1.ok === 'boolean' && Array.isArray(r1.inputs) && Array.isArray(r1.outputs)
+ && r1.outputs.every((o) => typeof o.sha256 === 'string' && typeof o.bytes === 'number'));
+
+ const ledgerPath = path.join(personDir, 'knowledge', 'index.json');
+ const ledger1 = JSON.parse(await readFile(ledgerPath, 'utf8'));
+ const h2 = distilly(['harvest', path.join(workdir, 'corpus'), '--person', person, '--json']);
+ const ledger2 = JSON.parse(await readFile(ledgerPath, 'utf8'));
+ record('重复 harvest 幂等', h2.status === 0 && ledger1.length === ledger2.length,
+ `${ledger1.length} → ${ledger2.length} 条账本记录`);
+
+ const anchors = (ledger2.flatMap((e) => e.anchors ?? []));
+ record('账本里有锚点且有正文', anchors.length > 0, `${anchors.length} 个锚点`);
+
+ // 2. retrospect(确定性)
+ const runRetrospect = () => {
+ const r = distilly(['retrospect', '--person', person, '--json']);
+ return { status: r.status, receipt: parseReceipt(r.stdout) };
+ };
+ const retro1 = runRetrospect();
+ const derivedDir = path.join(personDir, 'evidence', 'derived');
+ const hashDir = async (dir) => {
+ const { readdir } = await import('node:fs/promises');
+ const names = (await readdir(dir)).sort();
+ const hashes = {};
+ for (const n of names) hashes[n] = sha256(await readFile(path.join(dir, n)));
+ return hashes;
+ };
+ const d1 = await hashDir(derivedDir);
+ runRetrospect();
+ const d2 = await hashDir(derivedDir);
+ record('retrospect 两次产物字节相同', JSON.stringify(d1) === JSON.stringify(d2), Object.keys(d1).join(', '));
+
+ const knownAnchors = new Set(anchors.map((a) => (typeof a === 'string' ? a : a.id)));
+ let dangling = 0;
+ for (const [name, text] of Object.entries(d1)) {
+ const body = await readFile(path.join(derivedDir, name), 'utf8');
+ for (const m of body.matchAll(/"(k\d{4}(?::t\d+)?)"/g)) if (!knownAnchors.has(m[1])) dangling += 1;
+ }
+ record('派生结论里的锚点全部可回指', dangling === 0, dangling ? `${dangling} 个悬空锚点` : '0 悬空');
+
+ // 3. view check / render。
+ //
+ // 以前这里读 `expected/view.template.json` 再按序替换 `{{ANCHOR:n}}`。那份模板是
+ // **契约之前**的形状(sections 叫 voice/work/relations,只有 6 段且顺序与契约不符),
+ // 于是 `view check` 必然失败。改成用 `scripts/blind-test.mjs` 里那套已有的
+ // 「按派生结论机械填段」的构造器:形状由 `REQUIRED_SECTIONS` 保证,引用的是真实锚点,
+ // 派生不出来的段记成缺口而不是编造。
+ const derived = {};
+ for (const name of await (await import('node:fs/promises')).readdir(derivedDir)) {
+ derived[name.replace(/\.json$/, '')] = JSON.parse(await readFile(path.join(derivedDir, name), 'utf8'));
+ }
+ const anchorIndex = new Map();
+ for (const entry of ledger2) {
+ const raw = entry.locations?.raw ?? null;
+ for (const anchor of entry.anchors ?? []) {
+ const id = typeof anchor === 'string' ? anchor : anchor.id;
+ const base = id.split(':')[0];
+ const detail = (entry.anchor_detail ?? []).find((d) => (d.anchor ?? d.id) === id) ?? {};
+ if (!anchorIndex.has(base)) {
+ // 形状与 `scripts/blind-test.mjs` 的锚点索引保持一致:`source` 取账本的
+ // `kind`(subtitle / chat / doc …),`path` 指向归一化正文。原来这里取
+ // `entry.source`,而账本根本没有这个字段 —— 于是每个条目都因
+ // "evidence[].source is required" 报错,27 条一起红。
+ anchorIndex.set(base, {
+ id: entry.id ?? base,
+ anchor: base,
+ source: entry.kind ?? 'note',
+ kind: entry.kind ?? 'note',
+ path: `knowledge/${entry.locations?.text ?? 'index.json'}`,
+ at: entry.fetched_at ?? null,
+ });
+ }
+ void detail;
+ }
+ }
+ const baseline = baselineSections({ claims: derived, anchors: anchorIndex });
+ const view = buildView({ slug: person, sections: baseline.sections, evidence: baseline.evidence });
+ const viewsDir = path.join(personDir, 'views');
+ await mkdir(viewsDir, { recursive: true });
+ await writeFile(path.join(viewsDir, `${person}.view.json`), `${JSON.stringify(view, null, 2)}\n`, 'utf8');
+ // 七段**都必须在场**(页面顺序是契约的一部分),填不出来的段渲染成「本节证据不足」
+ // 而不是被丢掉;缺口数量单独报出来,免得"7 段"这个数字掩盖了内容稀薄。
+ record('view 由派生证据机械构造', baseline.sections.length === 7 && baseline.cited > 0,
+ `${baseline.sections.length} 段 / ${baseline.cited} 个锚点 / 其中 ${baseline.gaps.length} 段记为缺口`);
+
+ // `--allow-missing`:验收语料没有绝对时间戳,时间线一段必然是缺口。缺口要
+ // 渲染成「本节证据不足」而不是被丢掉,所以这个开关是**声明**而非消音 ——
+ // 引用不存在锚点之类的真错误仍然会让 check 失败。
+ const check = distilly(['view', 'check', '--person', person, '--allow-missing', '--json']);
+ record('view check 通过', check.status === 0, check.stderr.slice(0, 160));
+
+ const render1 = distilly(['view', 'render', '--person', person, '--allow-missing', '--json']);
+ const htmlPath = path.join(viewsDir, `${person}.html`);
+ const html1 = await readFile(htmlPath);
+ distilly(['view', 'render', '--person', person, '--allow-missing', '--json']);
+ const html2 = await readFile(htmlPath);
+ record('render 两次产物字节相同', sha256(html1) === sha256(html2), `${html1.length} bytes`);
+ const html = html1.toString('utf8');
+ record('产物单文件且无外链', !/https?:\/\//i.test(html.replace(/https?:\/\/www\.w3\.org[^"']*/g, '')), '');
+ record('产物含 CSP', /Content-Security-Policy/i.test(html));
+
+ // 4. visual-check(八项)
+ const vcScript = path.join(root, 'scripts/visual-check.mjs');
+ const vc = spawnSync('node', [vcScript, htmlPath, '--out', evidenceDir], { cwd: workdir, encoding: 'utf8' });
+ // The hint that playwright is missing goes to **stderr**, so a row that quoted
+ // only stdout went red with an empty reason — a gate nobody can act on.
+ const vcDetail =
+ (vc.stdout ?? '').trim().split('\n').slice(-3).join(' / ') ||
+ (vc.stderr ?? '').trim().split('\n').slice(-2).join(' / ');
+ if (/Cannot find module|ENOENT/.test(vc.stderr ?? '')) {
+ record('visual-check 可用', false, 'scripts/visual-check.mjs 尚不存在(ds/03-render 的产出)');
+ } else if (/DISTILLY_PLAYWRIGHT_ROOT/.test(vc.stderr ?? '')) {
+ record('visual-check 可用', false, '未提供 playwright:设 DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录>');
+ } else {
+ record('visual-check 八项通过', vc.status === 0, vcDetail);
+ }
+
+ // 5. 交付物:Distill 的最后一公里。
+ //
+ // 这一段以前不存在。验收的 12 项全部围绕 harvest → retrospect → view → render,
+ // **没有一步碰 `skill create`**,于是 SKILL.md —— 这个产品真正交付的东西 —— 可以
+ // 完全不存在而门禁全绿。加进来的判据是机械层能保证的部分:产物齐、六层结构在、
+ // 每条规则都带能回指的锚点。内容由 distillFixture 从语料原文机械拼出。
+ const textDir = path.join(personDir, 'knowledge', 'text');
+ const bodies = await Promise.all(
+ (await (await import('node:fs/promises')).readdir(textDir)).map((name) => readFile(path.join(textDir, name), 'utf8')),
+ );
+ const units = bodies.flatMap((body) => unitsOf(body));
+ const fixture = distillFixture({ units, displayName: person });
+
+ const fixturePaths = {
+ work: path.join(workdir, '.acceptance-work.md'),
+ persona: path.join(workdir, '.acceptance-persona.md'),
+ meta: path.join(workdir, '.acceptance-meta.json'),
+ };
+ await writeFile(fixturePaths.work, fixture.work, 'utf8');
+ await writeFile(fixturePaths.persona, fixture.persona, 'utf8');
+ await writeFile(
+ fixturePaths.meta,
+ `${JSON.stringify({ name: person, display_name: person, character: 'colleague' }, null, 2)}\n`,
+ 'utf8',
+ );
+
+ const created = distilly([
+ 'skill', 'create',
+ '--character', 'colleague',
+ '--slug', person,
+ '--base-dir', workdir,
+ '--meta', fixturePaths.meta,
+ '--work', fixturePaths.work,
+ '--persona', fixturePaths.persona,
+ '--no-install-claude-skill',
+ '--json',
+ ]);
+ const skillDir = path.join(personDir);
+ const delivered = {};
+ for (const name of REQUIRED_ARTIFACTS) {
+ const file = path.join(skillDir, name);
+ delivered[name] = existsSync(file) ? await readFile(file, 'utf8') : null;
+ }
+ const inspected = inspectSkillArtifacts(delivered, knownAnchors);
+
+ record(
+ 'skill create 产出完整交付物',
+ created.status === 0 && inspected.missingArtifacts.length === 0,
+ inspected.missingArtifacts.length === 0
+ ? `${REQUIRED_ARTIFACTS.length} 个产物`
+ : `缺: ${inspected.missingArtifacts.join(', ')};${created.stderr?.slice(0, 120)}`,
+ );
+ record(
+ '交付物含 PART A / PART B / 运行规则',
+ inspected.missingSections.length === 0,
+ inspected.missingSections.length ? `缺: ${inspected.missingSections.join(', ')}` : '',
+ );
+ record(
+ '交付物六层结构齐且 Layer 0 有规则',
+ inspected.missingLayers.length === 0 && inspected.layer0Rules > 0,
+ inspected.missingLayers.length
+ ? `缺: ${inspected.missingLayers.join(', ')}`
+ : `Layer 0 规则 ${inspected.layer0Rules} 条`,
+ );
+ record(
+ '交付物里的锚点全部可回指',
+ inspected.cited.length > 0 && inspected.dangling.length === 0,
+ `${inspected.cited.length} 个锚点,悬空 ${inspected.dangling.length}` +
+ (inspected.dangling.length ? `: ${inspected.dangling.slice(0, 5).join(', ')}` : ''),
+ );
+
+ // 语料体检:**报告** verdict 与理由。这里不把它当门槛,因为验收的第二份语料
+ // (C-SPAN 议会记录)本来就是"错误形状"的样本 —— 它必须被判 FAIL 才说明这个检查有用。
+ // 机械判据是"FAIL 必须给理由",不是"必须 PASS":不允许静默降级。
+ const shapeRun = distilly(['doctor', '--base-dir', workdir, '--json']);
+ const shapeReceipt = parseReceipt(shapeRun.stdout);
+ const shape = shapeReceipt?.shape?.[0] ?? null;
+ const shapeOk =
+ shapeRun.status === 0 &&
+ shape !== null &&
+ ['PASS', 'FAIL'].includes(shape.verdict) &&
+ (shape.verdict === 'PASS' || (shape.reasons ?? []).length > 0);
+ record(
+ '语料体检给出 verdict(FAIL 必须说明理由)',
+ shapeOk,
+ shape
+ ? `${shape.verdict} · units=${shape.units} speakers=${shape.speakers} attributed=${shape.attributed_units}` +
+ (shape.verdict === 'FAIL' ? ` · ${shape.reasons.length} 条理由` : '')
+ : '无 shape 回执',
+ );
+
+ const doctor = distilly(['doctor', '--base-dir', workdir, '--json']);
+ const doctorReceipt = parseReceipt(doctor.stdout);
+ const citedRate = doctorReceipt ? `${doctorReceipt.anchors?.cited ?? 0}/${doctorReceipt.anchors?.total ?? 0}` : '无回执';
+ record(
+ 'doctor 报出锚点回指率',
+ doctor.status === 0 && (doctorReceipt?.anchors?.cited ?? 0) > 0,
+ citedRate,
+ );
+} catch (error) {
+ record('验收流程未中断', false, String(error.message).split('\n')[0]);
+} finally {
+ if (!keep) await rm(workdir, { recursive: true, force: true });
+ else console.log(`保留工作目录:${workdir}`);
+}
+
+console.log(`\n验收结果:${results.length - failed}/${results.length} 通过`);
+if (failed) {
+ console.log('未通过项(依赖尚未落地时属预期,落地后必须转绿):');
+ for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail}`);
+ process.exit(1);
+}
diff --git a/scripts/audit-objective.mjs b/scripts/audit-objective.mjs
new file mode 100644
index 00000000..67d981bb
--- /dev/null
+++ b/scripts/audit-objective.mjs
@@ -0,0 +1,336 @@
+#!/usr/bin/env node
+/**
+ * Audit the v2 objective against the tree, item by item.
+ *
+ * `scripts/acceptance.mjs` proves the *pipeline* works; this script proves the
+ * *scope* is closed: every demand in the objective has an artefact and a check,
+ * and every gap is named rather than discovered later. Each row is mechanical —
+ * it reads the tree, the registry and the git index, and it says what it read.
+ *
+ * node scripts/audit-objective.mjs [--json] [--skip-acceptance]
+ *
+ * Exit code 1 when a demand is unmet. "Known gap" rows report a documented
+ * shortfall (a contract channel that is not ported yet) and never fail the run:
+ * they exist so the report cannot quietly claim more than the build does.
+ */
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import { PLANNED, listCommands, resolveCommand } from "../src/commands/index.mjs";
+import { PENDING_CHANNELS } from "../src/commands/credentialed.mjs";
+import { listAgents } from "../src/hosts/agents.mjs";
+import { SCHEMA_VERSION } from "../src/skill/schema.mjs";
+
+const root = resolve(import.meta.dirname, "..");
+const json = process.argv.includes("--json");
+const skipAcceptance = process.argv.includes("--skip-acceptance");
+
+const rows = [];
+const record = (demand, ok, evidence, { gap = false } = {}) => {
+ rows.push({ demand, ok: Boolean(ok), evidence, gap });
+};
+
+const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
+/**
+ * `git rev-parse --verify --quiet [` → the ref's sha, or `null` when the ref
+ * does not exist.
+ *
+ * `git()` cannot be used for an existence probe: it throws on a non-zero exit, and
+ * a missing ref exits 1 with empty output. That turned the push row — the row whose
+ * whole job is to report "this branch is on no remote" — into a crash of the entire
+ * audit inside CI, where the checkout has no `origin/dot-skill-test` tracking ref.
+ */
+const gitRef = (ref) => {
+ try {
+ return execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { cwd: root, encoding: "utf8" }).trim();
+ } catch {
+ return null;
+ }
+};
+
+/** 1. Node single stack: no Python left anywhere in the tree. */
+{
+ const tracked = git("ls-files").split("\n");
+ const python = tracked.filter((path) => /\.py$/.test(path) || /(^|\/)requirements\.txt$/.test(path));
+ const ci = readFileSync(join(root, ".github", "workflows", "ci.yml"), "utf8");
+ const pythonCi = /python-version|setup-python|pip install/.test(ci);
+ record(
+ "Node 单栈:仓库里没有 Python / requirements.txt,CI 只跑 Node",
+ python.length === 0 && !pythonCi,
+ python.length === 0 ? `0 tracked .py files; CI jobs: ${(ci.match(/^\s{2}[a-z-]+:$/gm) ?? []).map((line) => line.trim()).join(" ")}` : `still tracked: ${python.slice(0, 5).join(", ")}`,
+ );
+}
+
+/** 2. Evidence spine: the on-disk contract is what the docs say. */
+{
+ const store = readFileSync(join(root, "src", "knowledge", "store.mjs"), "utf8");
+ const hasDirs = ["raw", "text", "index.json"].every((name) => store.includes(name));
+ const anchors = readFileSync(join(root, "src", "knowledge", "anchors.mjs"), "utf8");
+ const bracketed = /\$\{unit\.anchor\}\]|\[\$\{unit\.anchor\}\]|`\[\$\{/.test(anchors) || anchors.includes("`[${unit.anchor}]");
+ record(
+ "证据脊柱:knowledge/raw|text|index.json 与可回指锚点",
+ hasDirs && bracketed,
+ `store names ${hasDirs ? "raw/text/index.json" : "?"}; paragraph anchors render as [k0012] (contract form): ${bracketed}`,
+ );
+}
+
+/** 3. retrospect determinism + anchors on the public corpus. */
+{
+ const test = readFileSync(join(root, "tests", "retrospect.test.mjs"), "utf8");
+ const deterministic = /byte-identical/.test(test);
+ // Two mechanisms both prove resolvability: calling the resolver, or comparing
+ // against the ledger's declared anchor set. Accept either — grepping for one
+ // specific API name tests the implementation, not the behaviour.
+ const viaResolver = /resolveLedgerAnchor/.test(test);
+ const viaDeclaredSet = /declared/.test(test) && /ledgerAnchors|flatMap\(\(entry\) => entry\.anchors/.test(test);
+ const resolvable = viaResolver || viaDeclaredSet;
+ record(
+ "retrospect:确定性派生,每条结论带可回指锚点",
+ deterministic && resolvable,
+ `tests/retrospect.test.mjs asserts ${[
+ deterministic && "two runs byte-identical",
+ viaResolver && "anchors resolve via resolveLedgerAnchor",
+ viaDeclaredSet && "anchors resolve against the ledger's declared set",
+ ]
+ .filter(Boolean)
+ .join(" + ")}`,
+ );
+}
+
+/** 4. Single-file HTML render + visual-check. */
+{
+ const template = readFileSync(join(root, "assets", "distilly-template.html"), "utf8");
+ const offline = !/https?:\/\//.test(template.replace(/https?:\/\/www\.w3\.org[^"']*/g, ""));
+ const csp = /Content-Security-Policy/.test(template);
+ const visual = existsSync(join(root, "scripts", "visual-check.mjs"));
+ record(
+ "单文件 HTML:离线自包含 + CSP,visual-check 八项",
+ offline && csp && visual,
+ `template offline: ${offline}, CSP: ${csp}, scripts/visual-check.mjs: ${visual}`,
+ );
+}
+
+/** 5. Bilingual prompts + the lint that enforces them. */
+{
+ const lint = execFileSync(process.execPath, [join(root, "scripts", "prompt-lint.mjs")], { cwd: root, encoding: "utf8" })
+ .trim()
+ .split("\n")
+ .pop();
+ const clean = /0 finding/.test(lint);
+ record("prompt:五步改造 + 双语 + prompt lint 无发现", clean, lint);
+}
+
+/** 6. Coding-agent matrix. */
+{
+ const agents = listAgents();
+ const matrix = readFileSync(join(root, "src", "hosts", "agents.mjs"), "utf8");
+ const hosts = readFileSync(join(root, "docs", "v2", "HOSTS.md"), "utf8");
+ // `listAgents()` yields ids, not objects (asserted by tests/agents.test.mjs).
+ const documented = agents.every((id) => hosts.includes(id));
+ record(
+ "coding-agent 适配矩阵:每个宿主的路径 / 确切命令有出处",
+ agents.length >= 6 && documented,
+ `${agents.length} hosts (${agents.join(", ")}), all named in docs/v2/HOSTS.md: ${documented}`,
+ );
+}
+
+/** 7. Credentialed channels + computer-use consent. */
+{
+ const consent = readFileSync(join(root, "src", "consent.mjs"), "utf8");
+ const gated = /waiting-for-user-consent/.test(consent);
+ const browser = readFileSync(join(root, "src", "collect", "feishu-browser.mjs"), "utf8");
+ const noDriver = !/from\s+["']playwright/.test(browser);
+ const configured = Object.keys(PENDING_CHANNELS);
+ record(
+ "要 key 渠道 + computer-use 同意协议(无同意 exit 2,不驱动浏览器)",
+ gated && noDriver,
+ `consent gate: ${gated}, browser route never drives a browser: ${noDriver}, credentialed channels: feishu/slack/dingtalk/x`,
+ );
+ // Every channel CONTRACT §1 names now ships, so `PENDING_CHANNELS` is empty and
+ // this row asserts that positively while naming the modules it read. It used to
+ // claim the remaining channels were unported and derive its evidence from the
+ // pending map — which, once the map emptied, produced a row whose "evidence" was
+ // the empty string: a passing check that said nothing.
+ const channelNames = ["feishu", "slack", "dingtalk", "x", "discord", "notion", "reddit", "gmail"];
+ const missingChannels = channelNames.filter((name) => !existsSync(join(root, "src", "collect", `${name}.mjs`)));
+ record(
+ "契约的八个采集渠道全部落地(feishu/slack/dingtalk/x/discord/notion/reddit/gmail)",
+ missingChannels.length === 0 && configured.length === 0,
+ `channel modules read: ${channelNames.map((name) => `src/collect/${name}.mjs`).join(", ")}; ` +
+ `missing: ${missingChannels.length === 0 ? "none" : missingChannels.join(", ")}; ` +
+ `PENDING_CHANNELS: ${configured.length === 0 ? "empty" : configured.join(", ")}`,
+ );
+}
+
+/** 8. schema v4 + idempotent migration. */
+{
+ const migration = existsSync(join(root, "src", "skill", "migrate.mjs"));
+ const test = readFileSync(join(root, "tests", "schema-migration.test.mjs"), "utf8");
+ const idempotent = /idempot/i.test(test);
+ record(
+ `schema v${SCHEMA_VERSION} + 幂等迁移`,
+ SCHEMA_VERSION === "4" && migration && idempotent,
+ `SCHEMA_VERSION=${SCHEMA_VERSION}, src/skill/migrate.mjs: ${migration}, idempotency asserted: ${idempotent}`,
+ );
+}
+
+/** 9. Contract command surface. */
+{
+ const contract = readFileSync(join(root, "docs", "v2", "CONTRACT.md"), "utf8");
+ const block = contract.slice(contract.indexOf("## 1. 命令契约"), contract.indexOf("迁移期兼容"));
+ const names = [...block.matchAll(/^([a-z][a-z0-9-]*)(?:\s+<[^\n]*?>)?(?:\s|$)/gm)]
+ .map((match) => match[1])
+ .filter((name, index, all) => all.indexOf(name) === index && name !== "collect" && name !== "view" && name !== "skill" && name !== "consent");
+ const missing = names.filter((name) => !resolveCommand([name]).name || resolveCommand([name]).name !== name);
+ const planned = Object.keys(PLANNED);
+ record(
+ "CONTRACT §1 的每个命令都能解析,PLANNED 为空",
+ missing.length === 0 && planned.length === 0,
+ `contract commands checked: ${names.join(", ")}; missing: ${missing.length === 0 ? "none" : missing.join(", ")}; PLANNED: ${planned.length === 0 ? "empty" : planned.join(", ")}`,
+ );
+ const registered = listCommands().length;
+ record("命令注册表非空且全部有双语帮助", registered >= 15, `${registered} registered command names`);
+}
+
+/** 10. Screenshots stay out of the repository. */
+{
+ // Every intended path has to be ignored, and the probe has to be a path *inside*
+ // the ignored directory. Two ways this row used to pass while proving nothing:
+ // it asked about `dst-evidence` (no trailing slash), which the directory pattern
+ // `dst-evidence/` does not match when the directory is absent — the answer was
+ // `false` and the row still went green because it accepted **any** of the probes
+ // (`.some`); and `evidence/renders` covered for it.
+ const probes = ["dst-evidence/screenshot.png", "evidence/renders/receipt.json"];
+ const ignored = probes.map((path) => {
+ try {
+ git("check-ignore", "-q", path);
+ return { path, ok: true };
+ } catch {
+ return { path, ok: false };
+ }
+ });
+ const trackedEvidence = git("ls-files").split("\n").filter((path) => /dst-evidence\/|screenshots\/.*\.png$/.test(path));
+ record(
+ "截图不入库:证据目录被忽略,仓库里没有 PNG 证据",
+ ignored.every((probe) => probe.ok) && trackedEvidence.length === 0,
+ `${ignored.map((probe) => `${probe.path}: ${probe.ok}`).join(", ")}; tracked evidence files: ${trackedEvidence.length}`,
+ );
+}
+
+/** 11. Per-PR evidence: every per-feature branch has a document. */
+{
+ // `git branch --merged HEAD` matched **zero** branches — none of the 19 per-feature
+ // branches is merged — so the row was true by having nothing to check, and the
+ // claim it makes ("every PR has a test / before-after / rollback document") was
+ // never verified. The population that matters is every local `ds/NN-*` branch,
+ // merged or not.
+ const branches = git("branch", "--list", "ds/*")
+ .split("\n")
+ .map((line) => line.replace(/^[*+]\s*/, "").trim())
+ .filter((name) => /^ds\/\d\d-/.test(name))
+ .sort();
+ const docs = readdirSync(join(root, "docs", "evidence"));
+ const withoutDoc = branches.filter((branch) => {
+ const number = branch.slice(3, 5);
+ return !docs.some((doc) => doc.startsWith(`pr-${number}`));
+ });
+ // A CI checkout fetches one ref, so there are no `ds/*` branches to inspect there.
+ // That is a **declared gap**, not a pass (the row would otherwise go back to being
+ // true by having nothing to check — the very thing this rewrite removed) and not a
+ // failure either (CI cannot be asked to hold branches it was never given).
+ const inCi = process.env.GITHUB_ACTIONS === "true";
+ if (branches.length === 0) {
+ record(
+ "每个 ds/* 分支都有 PR 证据文档(测试 / 前后对比 / 回滚)",
+ true,
+ inCi
+ ? "CI checkout: 单 ref,检出了 0 条 ds/* 分支,这一行在这里无从检查"
+ : "本地没有任何 ds/* 分支可检查",
+ { gap: true },
+ );
+ } else {
+ record(
+ "每个 ds/* 分支都有 PR 证据文档(测试 / 前后对比 / 回滚)",
+ withoutDoc.length === 0,
+ `${branches.length} 条 ds/* 分支;缺文档: ${withoutDoc.length === 0 ? "无" : withoutDoc.join(", ")}`,
+ );
+ }
+}
+
+/** 12. The end-to-end gate itself. */
+if (skipAcceptance) {
+ record("端到端验收(本审计已跳过)", true, "--skip-acceptance was passed", { gap: true });
+} else {
+ // `DISTILLY_PLAYWRIGHT_ROOT` is **passed through, never invented**. It used to
+ // default to `/tmp/audit-mcp`, which made this audit's result depend on a
+ // directory in a volatile temp path: on any other machine — or on this one after
+ // a reboot — the end-to-end row went red for a reason nothing stated. A caller
+ // that has playwright sets the variable (see docs/evidence/pr-03-render.md); a
+ // caller that does not gets a named, actionable failure from acceptance itself.
+ const output = execFileSync(process.execPath, [join(root, "scripts", "acceptance.mjs")], {
+ cwd: root,
+ encoding: "utf8",
+ env: { ...process.env },
+ });
+ const summary = output.trim().split("\n").pop() ?? "";
+ const match = /(\d+)\/(\d+)\s*通过/.exec(summary);
+ const ok = match ? match[1] === match[2] : /PASS/.test(output);
+ record("公开语料端到端验收全绿", ok, summary);
+}
+
+/** 13. Push state: the branch is backed up off-site; no PR has been opened. */
+{
+ // The remote is not necessarily called `origin` — this checkout tracks
+ // `upstream`. Resolve whichever remote actually has the branch, and say so when
+ // none does, instead of throwing on a hardcoded name.
+ const remotes = git("remote").split("\n").map((line) => line.trim()).filter(Boolean);
+ const tracking = remotes.map((remote) => `${remote}/dot-skill-test`).find((ref) => gitRef(ref) !== null);
+ const ahead = tracking === undefined ? null : Number(git("rev-list", "--count", `${tracking}..HEAD`));
+ const dirty = git("status", "--porcelain").split("\n").filter(Boolean);
+
+ // "Pushed" is the thing the user asked for (an off-site copy). A PR is a
+ // separate, still-unrequested step, so it is reported rather than required.
+ // A dirty tree is counted as **the same risk** as an unpushed commit — the title
+ // has always claimed it, and uncommitted work is the more volatile of the two —
+ // so the check now enforces what the title says instead of merely printing it.
+ const pushed = ahead === 0 && dirty.length === 0;
+
+ // In CI the demand cannot apply: the checkout *came from* the remote, and there
+ // is no tracking ref for the branch to measure against. Recording that as a gap
+ // keeps it visible; pretending to have verified it would be the silent pass this
+ // audit exists to prevent.
+ const inCi = process.env.GITHUB_ACTIONS === "true";
+ record(
+ "推送:集成分支已推送到远端(异地备份),工作树干净",
+ inCi ? true : pushed,
+ inCi
+ ? `CI checkout: ${remotes.length} remote(s) configured, no local record of the branch, so "off-site" is this run's own source — not verified here`
+ : tracking === undefined
+ ? "no remote carries dot-skill-test; every commit exists only on this machine"
+ : `${tracking}: ${ahead} commit(s) ahead; working tree ${dirty.length === 0 ? "clean" : `${dirty.length} path(s) dirty`}; PR bodies staged in dst-evidence/PR-BODIES/`,
+ { gap: inCi || !pushed },
+ );
+}
+
+const failed = rows.filter((row) => !row.ok);
+const gaps = rows.filter((row) => row.gap);
+
+if (json) {
+ console.log(JSON.stringify({ ok: failed.length === 0, rows, failed: failed.map((row) => row.demand), gaps: gaps.map((row) => row.demand) }, null, 2));
+} else {
+ console.log("目标审计 / objective audit\n");
+ for (const row of rows) {
+ const mark = row.ok ? "✅" : "❌";
+ const tag = row.gap ? "(已知缺口)" : "";
+ console.log(`${mark} ${row.demand}${tag}`);
+ console.log(` ${row.evidence}`);
+ }
+ console.log(
+ `\n${rows.length - failed.length}/${rows.length} 条满足;已知缺口 ${gaps.length} 条;` +
+ `${failed.length === 0 ? "没有未满足项" : `未满足:${failed.map((row) => row.demand).join(";")}`}`,
+ );
+}
+
+process.exit(failed.length === 0 ? 0 : 1);
diff --git a/scripts/blind-test.mjs b/scripts/blind-test.mjs
new file mode 100644
index 00000000..92dc11a6
--- /dev/null
+++ b/scripts/blind-test.mjs
@@ -0,0 +1,586 @@
+#!/usr/bin/env node
+/**
+ * Blind-test runner for `docs/v2/ACCEPTANCE.md` §"效果层".
+ *
+ * node scripts/blind-test.mjs prepare --a ] --person --out [--baseline]
+ * node scripts/blind-test.mjs finalize --out --view [--strict]
+ * node scripts/blind-test.mjs control --a --out
+ * node scripts/blind-test.mjs score --scores [--out report.md]
+ *
+ * The protocol is an A/B holdout: the distiller only ever sees the A half, the
+ * judge only ever sees the rendered page (never the corpus), and the checker —
+ * who *has* read B — scores ten traits per arm for hit / partial / miss /
+ * undecidable / fabricated. This script does the mechanical half:
+ *
+ * prepare deterministic: harvest A → derive → write the authoring input,
+ * the canonical view skeleton, the judge prompt and a blank sheet.
+ * `--baseline` also fills the sections mechanically so a full run is
+ * reproducible in CI without a model in the loop.
+ * finalize validate + render an authored view.json into `profile.html`.
+ * control the same question for a run *without* the evidence layer.
+ * score turn the filled sheet into the three metrics and a verdict.
+ *
+ * The judge and the checker are models or people. This script never pretends to
+ * be either, and it never writes prose of its own into the page: the baseline is
+ * labelled `mechanical-baseline` in the receipt, and sections it cannot support
+ * are reported as gaps rather than padded with invented content.
+ */
+
+import { isEntryPoint } from "../src/cli/entry.mjs";
+import { spawnSync } from "node:child_process";
+import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { basename, join, relative, resolve } from "node:path";
+
+const root = resolve(import.meta.dirname, "..");
+const BIN = join(root, "bin", "distilly.mjs");
+const DERIVED_KINDS = ["voice", "stats", "relations", "shifts", "boundaries", "conflicts", "timeline"];
+
+/** The seven authored segments, in the fixed page order from `docs/v2/RENDER.md` §3.1. */
+const SECTIONS = [
+ { id: "portrait", kind: "claims", title: "一句话画像", from: [] },
+ { id: "communication", kind: "claims", title: "沟通风格", from: ["voice"] },
+ { id: "values", kind: "claims", title: "决策与价值观", from: ["shifts"] },
+ { id: "workstyle", kind: "claims", title: "工作方式", from: ["stats"] },
+ { id: "relationship", kind: "claims", title: "关系与称呼", from: ["relations"] },
+ { id: "boundaries", kind: "warnings", title: "边界与雷区", from: ["boundaries", "conflicts"] },
+ { id: "timeline", kind: "timeline", title: "时间线演变", from: ["timeline"], timeline: true },
+];
+
+/** Ledger kind → the evidence badge `docs/v2/RENDER.md` §3.3 suggests. */
+const EVIDENCE_KIND = { message: "message", chat: "message", email: "email", doc: "doc", document: "doc", note: "note" };
+
+function arg(name, fallback) {
+ const index = process.argv.indexOf(`--${name}`);
+ return index === -1 ? fallback : process.argv[index + 1];
+}
+const flag = (name) => process.argv.includes(`--${name}`);
+
+const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
+const round = (value) => (Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0$/, ""));
+
+function distilly(args, cwd, { tolerate = false } = {}) {
+ const result = spawnSync(process.execPath, [BIN, ...args], { cwd, encoding: "utf8" });
+ const stdout = result.stdout ?? "";
+ if (result.status !== 0 && !tolerate) {
+ throw new Error(`distilly ${args.join(" ")} failed (${result.status}): ${(result.stderr || stdout).slice(0, 600)}`);
+ }
+ let receipt = null;
+ const start = stdout.indexOf("{");
+ if (start !== -1) {
+ try {
+ receipt = JSON.parse(stdout.slice(start));
+ } catch {
+ receipt = null;
+ }
+ }
+ return { status: result.status, stdout, stderr: result.stderr ?? "", receipt };
+}
+
+/* ------------------------------------------------------------------ derivation */
+
+/** Everything `prepare` reads back out of the work directory. */
+function loadDerivation(personDir) {
+ const ledger = JSON.parse(readFileSync(join(personDir, "knowledge", "index.json"), "utf8"));
+ const derivedDir = join(personDir, "evidence", "derived");
+ const claims = {};
+ for (const kind of DERIVED_KINDS) {
+ const path = join(derivedDir, `${kind}.json`);
+ let document = { claims: [], notes: [] };
+ try {
+ document = JSON.parse(readFileSync(path, "utf8"));
+ } catch {
+ /* a kind that produced nothing is simply absent */
+ }
+ claims[kind] = document;
+ }
+
+ // anchor → where it came from, so the appendix can name a real source.
+ const anchors = new Map();
+ for (const entry of ledger) {
+ for (const anchor of entry.anchors ?? []) {
+ anchors.set(anchor, {
+ id: entry.id,
+ source: entry.kind ?? "note",
+ kind: EVIDENCE_KIND[entry.kind] ?? "note",
+ path: `knowledge/${entry.locations?.text ?? "index.json"}`,
+ at: entry.fetched_at ?? null,
+ });
+ }
+ }
+ return { ledger, claims, anchors };
+}
+
+function describeValue(value) {
+ if (typeof value === "number") return round(value);
+ if (typeof value === "string") return value;
+ if (Array.isArray(value)) return value.map(describeValue).join("、");
+ if (value && typeof value === "object") {
+ return Object.entries(value)
+ .map(([key, inner]) => {
+ if (inner === null || inner === undefined) return null;
+ if (Array.isArray(inner) || typeof inner === "object") return null;
+ return `${key} ${describeValue(inner)}`;
+ })
+ .filter(Boolean)
+ .join(";");
+ }
+ return String(value);
+}
+
+/** `label.zh:value`, clipped to the 280-character item budget. */
+function claimText(claim) {
+ const label = claim.label?.zh ?? claim.label?.en ?? claim.id;
+ const body = describeValue(claim.value);
+ const text = body ? `${label}:${body}` : label;
+ return text.length > 260 ? `${text.slice(0, 257)}…` : text;
+}
+
+const DATE = /^\d{4}(?:-\d{2}(?:-\d{2})?)?/;
+
+function itemFromClaim(claim, anchors) {
+ const cited = (claim.evidence ?? []).filter((anchor) => anchors.has(anchor));
+ if (cited.length === 0) return null;
+ const item = { text: claimText(claim), anchors: cited, confidence: claim.confidence ?? "medium" };
+ return item;
+}
+
+/**
+ * Fill the seven segments from the derived claims.
+ *
+ * The mapping is deliberately literal — one derived kind per segment — and the
+ * one-line portrait is assembled from the statistics rather than written, so the
+ * baseline cannot smuggle in a conclusion the evidence does not carry. Anything
+ * the derivation could not support (a dated timeline over a corpus without
+ * timestamps, say) is reported as a gap, never invented.
+ */
+export function baselineSections({ claims, anchors }) {
+ const sections = [];
+ const gaps = [];
+ const byId = new Map();
+
+ const all = DERIVED_KINDS.flatMap((kind) => (claims[kind]?.claims ?? []).map((claim) => ({ ...claim, kind })));
+ for (const claim of all) {
+ for (const anchor of claim.evidence ?? []) if (anchors.has(anchor)) (byId.get(anchor) ?? byId.set(anchor, []).get(anchor)).push(claim.id);
+ }
+
+ for (const section of SECTIONS) {
+ if (section.id === "portrait") {
+ const stats = Object.fromEntries((claims.stats?.claims ?? []).map((claim) => [claim.id, claim]));
+ const parts = [];
+ const cited = new Set();
+ const messages = stats["stats.message_count"];
+ const participants = stats["stats.participants"];
+ if (messages) {
+ parts.push(`可引用消息 ${describeValue(messages.value)} 条`);
+ for (const anchor of messages.evidence ?? []) cited.add(anchor);
+ }
+ if (participants) {
+ parts.push(`参与者 ${describeValue(participants.value)}`);
+ for (const anchor of participants.evidence ?? []) cited.add(anchor);
+ }
+ const usable = [...cited].filter((anchor) => anchors.has(anchor));
+ if (parts.length === 0 || usable.length === 0) {
+ gaps.push({ section: section.id, reason: "no derived statistics to anchor a one-line portrait" });
+ sections.push(gapSection(section, "派生统计不足以支撑一句话画像"));
+ continue;
+ }
+ sections.push({
+ id: section.id,
+ kind: section.kind,
+ title: section.title,
+ items: [{ text: `${parts.join(",")}。`, anchors: usable, confidence: "high" }],
+ });
+ continue;
+ }
+
+ const items = [];
+ for (const kind of section.from) {
+ for (const claim of claims[kind]?.claims ?? []) {
+ const item = itemFromClaim(claim, anchors);
+ if (!item) continue;
+ if (section.timeline) {
+ const at = [claim.value?.from, claim.value?.at, claim.value?.date].find((value) => typeof value === "string" && DATE.test(value));
+ if (!at) continue;
+ item.at = at;
+ }
+ items.push(item);
+ }
+ }
+ if (items.length === 0) {
+ const reason = section.timeline
+ ? "no derived phase carries a date; a timeline is not invented to fill the segment"
+ : `the derivation produced no claims for ${section.from.join("/")}`;
+ gaps.push({ section: section.id, reason });
+ sections.push(gapSection(section, reason));
+ continue;
+ }
+ sections.push({ id: section.id, kind: section.kind, title: section.title, items });
+ }
+
+ const cited = new Set(sections.flatMap((section) => section.items.flatMap((item) => item.anchors)));
+ const evidence = [...cited].sort().map((anchor) => {
+ const source = anchors.get(anchor);
+ return {
+ anchor,
+ source: source.source,
+ kind: source.kind,
+ path: source.path,
+ id: source.id,
+ note: `由 ${source.path} 经 retrospect 派生`,
+ };
+ });
+ return { sections, gaps, evidence, cited: cited.size };
+}
+
+export /**
+ * A segment the derivation could not fill.
+ *
+ * The page order is fixed and every authored segment must be present, so a gap is
+ * rendered *as a gap* — a segment that says so in words and cites nothing —
+ * rather than dropped. Dropping it would shift every later segment out of its
+ * contract position, and the reader would never learn that something is thin.
+ */
+function gapSection(section, reason) {
+ return {
+ id: section.id,
+ kind: section.kind,
+ title: section.title,
+ items: [{ text: `本节证据不足:${reason}。`, confidence: "low", anchors: [] }],
+ // Every declared gap carries the marker, not only the timeline: `view check`
+ // downgrades "a segment is thin" to a warning *because* the section says it has
+ // no evidence. A gap section without the marker is an error by definition — so
+ // a page honestly reporting its own gaps could not be rendered at all.
+ gap: true,
+ };
+}
+
+export function buildView({ slug, sections, evidence }) {
+ return {
+ meta: { slug, title: "沟通与协作画像(派生证据版)", lang: "zh-CN", theme: "auto" },
+ sections,
+ evidence,
+ };
+}
+
+/* ------------------------------------------------------------------ commands */
+
+function writeJudgePrompt(out) {
+ writeFileSync(
+ join(out, "judge-prompt.md"),
+ `# 裁判任务(盲测)
+
+你只看到一份**私有模式**的人物画像页面:profile.html。你没有看过原始语料,也不要去猜它是谁。
+
+请回答:
+
+1. 用 **10 条**句子写出这个人的特征。每条必须是**可验证的**(能被"看原文的人"判定对/错),不要写"他很专业"这类无法判定的空话。
+2. 每条特征后面再写**一句预测**:他在一个没出现过的新场景里会怎么做(例如"临时被要求周末上线会怎么回应")。
+
+规则:
+- 只依据页面里的结论与锚点编号;页面默认不含原话。
+- 不确定就写"无法判断",不要编。编造会被单独统计,是最严重的失败。
+
+把答案写回:每行 \`特征 | 预测\`,共 10 行。
+`,
+ "utf8",
+ );
+}
+
+function defaultScores() {
+ const blank = () => Array.from({ length: 10 }, (_, index) => ({ n: index + 1, trait: "", prediction: "", verdict: null, support: "" }));
+ return { evidence: blank(), control: blank() };
+}
+
+function writeSheet(out) {
+ const path = join(out, "scores.template.json");
+ let sheet = defaultScores();
+ try {
+ sheet = { ...sheet, ...JSON.parse(readFileSync(path, "utf8")) };
+ } catch {
+ /* first run */
+ }
+ writeFileSync(path, `${JSON.stringify(sheet, null, 2)}\n`, "utf8");
+}
+
+function prepare() {
+ const a = arg("a");
+ const person = arg("person");
+ const out = arg("out");
+ if (!a || !person || !out) {
+ console.error("usage: node scripts/blind-test.mjs prepare --a --person --out [--skeleton-only]");
+ process.exit(2);
+ }
+ const work = join(out, "work");
+ rmSync(work, { recursive: true, force: true });
+ mkdirSync(join(work, "corpus"), { recursive: true });
+ cpSync(a, join(work, "corpus", basename(a)));
+ distilly(["harvest", join(work, "corpus"), "--person", person, "--base-dir", work, "--json"], work);
+ const retro = distilly(["retrospect", "--person", person, "--json"], work);
+
+ const personDir = join(work, "skills", "colleague", person);
+ const derivation = loadDerivation(personDir);
+ mkdirSync(out, { recursive: true });
+
+ // What the author (a model, in the real workflow) reads: claims + the anchor
+ // table, never the corpus itself.
+ const lines = ["# 蒸馏输入(仅 A 半段,私有模式)", "", `person: ${person}`, ""];
+ for (const kind of DERIVED_KINDS) {
+ const document = derivation.claims[kind];
+ lines.push(`## ${kind}`, "");
+ for (const note of document.notes ?? []) lines.push(`- 注:${note}`);
+ for (const claim of document.claims ?? []) {
+ lines.push(`- [${claim.confidence ?? "medium"}] ${claimText(claim)} — 锚点 ${(claim.evidence ?? []).join(" ")}`);
+ }
+ if ((document.claims ?? []).length === 0 && (document.notes ?? []).length === 0) lines.push("- (无)");
+ lines.push("");
+ }
+ lines.push("## 锚点表", "");
+ for (const [anchor, source] of [...derivation.anchors].sort()) {
+ lines.push(`- ${anchor} ← ${source.path}(${source.source}${source.at ? ` · ${source.at}` : ""})`);
+ }
+ writeFileSync(join(out, "deriver-input.md"), `${lines.join("\n")}\n`, "utf8");
+
+ const skeleton = buildView({ slug: person, sections: SECTIONS.map(({ id, kind, title }) => ({ id, kind, title, items: [] })), evidence: [] });
+ writeFileSync(join(out, "view.skeleton.json"), `${JSON.stringify(skeleton, null, 2)}\n`, "utf8");
+
+ const receipt = {
+ arm: "evidence",
+ corpus: { file: basename(a), sha256: sha256(readFileSync(a, "utf8")) },
+ derivation: {
+ anchors_total: derivation.anchors.size,
+ anchors_cited: retro.receipt?.anchors?.cited ?? 0,
+ claims: Object.fromEntries(DERIVED_KINDS.map((kind) => [kind, (derivation.claims[kind]?.claims ?? []).length])),
+ warnings: retro.receipt?.warnings ?? [],
+ },
+ view_source: "skeleton",
+ next_steps: [
+ "author the seven segments from deriver-input.md into view.skeleton.json (or rerun prepare with --baseline)",
+ `node scripts/blind-test.mjs finalize --out ${out} --view ${out}/view.authored.json`,
+ "hand profile.html + judge-prompt.md to a model that has not seen the corpus",
+ `node scripts/blind-test.mjs control --a ${a} --out ${out}`,
+ "score both arms with `node scripts/blind-test.mjs score --scores " + out + "/scores.json`",
+ ],
+ };
+
+ // The page is rendered by default: a judge handed `judge-prompt.md` with no
+ // `profile.html` has nothing to read, which is how this arm could "pass" while
+ // producing no artefact at all. `--skeleton-only` keeps the old behaviour (write
+ // the skeleton and stop, for a model that will author the view itself); the
+ // historical `--baseline` flag still means the same thing it always did.
+ if (flag("baseline") || !flag("skeleton-only")) {
+ const built = baselineSections(derivation);
+ const view = buildView({ slug: person, sections: built.sections, evidence: built.evidence });
+ const viewPath = join(out, "view.baseline.json");
+ writeFileSync(viewPath, `${JSON.stringify(view, null, 2)}\n`, "utf8");
+ const checked = finalizeView({ out, viewPath, slug: person, personDir, receipt, source: "mechanical-baseline" });
+ Object.assign(receipt, checked);
+ receipt.view_source = "mechanical-baseline";
+ receipt.sections_filled = built.sections.map((section) => `${section.id}(${section.items.length})`);
+ receipt.gaps = built.gaps;
+ receipt.caveat =
+ "the baseline fills each segment from one derived kind and writes no prose of its own; " +
+ "an authored page replaces it, and any section listed in `gaps` was left out rather than invented";
+ }
+
+ writeFileSync(join(out, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
+ writeJudgePrompt(out);
+ writeSheet(out);
+ console.log(`prepared ${out} (view_source: ${receipt.view_source}, gaps: ${(receipt.gaps ?? []).length})`);
+ if (receipt.html) console.log(` page → ${out}/profile.html (${receipt.html.bytes} bytes, external links: ${receipt.external_links})`);
+ else console.log(` skeleton → ${out}/view.skeleton.json · authoring input → ${out}/deriver-input.md`);
+}
+
+/** Validate + render one view document, then copy the page next to the receipt. */
+/**
+ * Distinct anchors cited by a rendered page.
+ *
+ * The page embeds its view data as JSON and the viewer turns each citation into
+ * markup at runtime, so the file itself contains `"anchor": "k00NN"` rather than
+ * `[k00NN]`. Counting the bracket form (as this first did) reported 0 for a page
+ * full of citations.
+ */
+function countRenderedAnchors(html) {
+ const embedded = /"sections"\s*:\s*\[/.test(html) ? html : "";
+ const cited = new Set();
+ for (const match of embedded.matchAll(/"anchors"\s*:\s*\[([^\]]*)\]/g)) {
+ for (const anchor of match[1].matchAll(/"(k\d{4}(?::t\d+)?)"/g)) cited.add(anchor[1]);
+ }
+ if (cited.size === 0) {
+ for (const match of html.matchAll(/"anchor"\s*:\s*"(k\d{4}(?::t\d+)?)"/g)) cited.add(match[1]);
+ }
+ return cited.size;
+}
+
+function finalizeView({ out, viewPath, slug, personDir, receipt, source }) {
+ const checked = distilly(["view", "check", "--file", viewPath, "--json"], personDir, { tolerate: true });
+ const rendered = distilly(["view", "render", "--file", viewPath, "--out", join(out, "profile.html"), "--json"], personDir, {
+ tolerate: true,
+ });
+ const diagnostics = [];
+ for (const entry of checked.receipt?.diagnostics ?? []) diagnostics.push(`${entry.code}: ${entry.message}`);
+ const html = readFileSync(join(out, "profile.html"), "utf8");
+ writeFileSync(join(out, "view-diagnostics.json"), `${JSON.stringify(checked.receipt ?? {}, null, 2)}\n`, "utf8");
+ return {
+ view_source: source,
+ view: { file: basename(viewPath), sha256: sha256(readFileSync(viewPath, "utf8")) },
+ check: { ok: checked.receipt?.ok ?? false, exit: checked.status, diagnostics },
+ render: { ok: rendered.receipt?.ok ?? false, exit: rendered.status },
+ html: { file: "profile.html", sha256: sha256(html), bytes: Buffer.byteLength(html) },
+ external_links: /(?:src|href)\s*=\s*["']https?:/i.test(html) ? "present" : "none",
+ // How many distinct anchors the page cites, read back out of the rendered HTML
+ // rather than from the view spec: the judge's page is the artefact under test,
+ // and a citation that did not survive rendering must not be counted. The
+ // citations live in the embedded view-data JSON (the viewer renders them into
+ // `data-anchor-ref` attributes at runtime), so the count comes from there.
+ anchors: countRenderedAnchors(html),
+ };
+}
+
+function finalize() {
+ const out = arg("out");
+ const view = arg("view");
+ if (!out || !view) {
+ console.error("usage: node scripts/blind-test.mjs finalize --out --view [--strict]");
+ process.exit(2);
+ }
+ const receiptPath = join(out, "receipt.json");
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
+ const person = receipt.view?.slug ?? JSON.parse(readFileSync(view, "utf8")).meta?.slug;
+ const personDir = join(out, "work", "skills", "colleague", person);
+ const result = finalizeView({ out, viewPath: resolve(view), slug: person, personDir, receipt, source: "authored" });
+ Object.assign(receipt, result, { view_source: "authored", next_steps: receipt.next_steps });
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
+ console.log(`finalized ${out}/profile.html (${result.html.bytes} bytes, check ok: ${result.check.ok}, external links: ${result.external_links})`);
+ for (const line of result.check.diagnostics.slice(0, 8)) console.log(` ${line}`);
+ if (flag("strict") && !result.check.ok) process.exit(1);
+}
+
+const CONTROL_PROMPT = (source) => `# 对照任务(裸 prompt,无证据层)
+
+下面是同一段语料的**原文**(${source})。请**不要**运行任何命令、不要生成知识库或派生文件,只凭这段文字直接写出这个人的画像。
+
+要求与实验组完全一致:8 个部分(一句话画像 / 沟通风格 / 决策与价值观 / 工作方式 / 关系与称呼 / 边界与雷区 / 时间线演变 / 证据附录),以及 **10 条可验证特征 + 每条一句预测**。
+
+这一段用于对照:如果"没有证据层"也能得到同样的命中率与编造率,那证据层就是多余的。
+
+---
+
+`;
+
+function control() {
+ const a = arg("a");
+ const out = arg("out");
+ if (!a || !out) {
+ console.error("usage: node scripts/blind-test.mjs control --a --out ");
+ process.exit(2);
+ }
+ mkdirSync(out, { recursive: true });
+ writeFileSync(join(out, "control-prompt.md"), CONTROL_PROMPT(basename(a)) + readFileSync(a, "utf8"), "utf8");
+ writeSheet(out);
+ console.log(`control prompt → ${out}/control-prompt.md (paste the model's answer into control-profile.md)`);
+}
+
+/* ------------------------------------------------------------------ scoring */
+
+const VERDICTS = { hit: 1, partial: 0.5, miss: 0, undecidable: null, "": null, null: null };
+
+export function armMetrics(rows) {
+ let score = 0;
+ let decidable = 0;
+ let undecidable = 0;
+ let fabrication = 0;
+ for (const row of rows ?? []) {
+ const verdict = row?.verdict ?? "";
+ if (verdict === "fabricated") {
+ fabrication += 1;
+ decidable += 1;
+ continue;
+ }
+ const value = VERDICTS[verdict];
+ if (value === null || value === undefined) {
+ undecidable += 1;
+ continue;
+ }
+ score += value;
+ decidable += 1;
+ }
+ const total = (rows ?? []).length;
+ return {
+ rows: total,
+ decidable,
+ undecidable,
+ fabrication,
+ hit_rate: decidable === 0 ? 0 : Number((score / decidable).toFixed(3)),
+ undecidable_ratio: total === 0 ? 1 : Number((undecidable / total).toFixed(3)),
+ };
+}
+
+export function verdictFor(metrics) {
+ const reasons = [];
+ if (metrics.fabrication > 0) reasons.push(`${metrics.fabrication} fabricated claim(s) — FALSIFIED`);
+ if (metrics.hit_rate < 0.7) reasons.push(`hit rate ${metrics.hit_rate} < 0.7`);
+ if (metrics.undecidable_ratio > 0.2) reasons.push(`undecidable ${metrics.undecidable_ratio} > 0.2`);
+ return { pass: reasons.length === 0, reasons };
+}
+
+function score() {
+ const scoresPath = arg("scores");
+ if (!scoresPath) {
+ console.error("usage: node scripts/blind-test.mjs score --scores [--out report.md]");
+ process.exit(2);
+ }
+ const scores = JSON.parse(readFileSync(scoresPath, "utf8"));
+ const arms = ["evidence", "control"].filter((arm) => Array.isArray(scores[arm]));
+ if (arms.length === 0) throw new Error("scores.json needs an `evidence` and/or `control` array");
+
+ const metrics = {};
+ for (const arm of arms) metrics[arm] = armMetrics(scores[arm]);
+ const lines = ["| 指标 | " + arms.join(" | ") + " |", "| --- | " + arms.map(() => "---").join(" | ") + " |"];
+ const rows = [
+ ["条目数", (m) => m.rows],
+ ["可判定", (m) => m.decidable],
+ ["无法判定", (m) => m.undecidable],
+ ["命中率(hit + 0.5×partial)", (m) => m.hit_rate],
+ ["无法判定比例", (m) => m.undecidable_ratio],
+ ["**编造数**", (m) => m.fabrication],
+ ];
+ for (const [label, pick] of rows) lines.push(`| ${label} | ${arms.map((arm) => pick(metrics[arm])).join(" | ")} |`);
+
+ const verdicts = Object.fromEntries(arms.map((arm) => [arm, verdictFor(metrics[arm])]));
+ lines.push("", `判定:${arms.map((arm) => `${arm} → ${verdicts[arm].pass ? "PASS" : "FAIL"}`).join(";")}`);
+ for (const arm of arms) for (const reason of verdicts[arm].reasons) lines.push(`- ${arm}: ${reason}`);
+ if (arms.length === 2) {
+ const delta = Number((metrics.evidence.hit_rate - metrics.control.hit_rate).toFixed(3));
+ lines.push(
+ "",
+ `反向对照:证据层命中率 ${metrics.evidence.hit_rate} vs 裸 prompt ${metrics.control.hit_rate}(差 ${delta >= 0 ? "+" : ""}${delta}),` +
+ `编造数 ${metrics.evidence.fabrication} vs ${metrics.control.fabrication}。`,
+ );
+ }
+
+ const report = lines.join("\n") + "\n";
+ const out = arg("out");
+ if (out) {
+ writeFileSync(out, report, "utf8");
+ console.log(`report → ${out}`);
+ }
+ console.log(report);
+}
+
+const [, , command] = process.argv;
+// Collected by `node --test` because the filename matches its `*-test.mjs` pattern:
+// it is a CLI, not a suite, so declare nothing and leave quietly.
+const collectedByRunner = Boolean(process.env.NODE_TEST_CONTEXT) && command === undefined;
+if (collectedByRunner) {
+ // deliberately empty
+} else if (!isEntryPoint(import.meta.url)) {
+ // imported for its helpers (`acceptance.mjs` does this): nothing to run
+} else if (command === "prepare") prepare();
+else if (command === "finalize") finalize();
+else if (command === "control") control();
+else if (command === "score") score();
+else {
+ console.error("usage: node scripts/blind-test.mjs …");
+ process.exit(2);
+}
diff --git a/scripts/check-agent-ids.mjs b/scripts/check-agent-ids.mjs
new file mode 100644
index 00000000..b7d67a07
--- /dev/null
+++ b/scripts/check-agent-ids.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/**
+ * Optional check: is every `cliId` in the host matrix still a target the
+ * upstream AgentSkills CLI accepts?
+ *
+ * node scripts/check-agent-ids.mjs
+ *
+ * The matrix (`src/hosts/agents.mjs`) maps our host ids to the `--agent` value
+ * the upstream `skills` CLI publishes, and those two names do not always match
+ * (`grok-build` → `grok`, `hermes` → `hermes-agent`). Upstream can rename a
+ * target in any release, so this script replays `skills ls -a ` for each
+ * host and reports the ones it rejects.
+ *
+ * It is deliberately NOT part of the test gate: it needs the upstream CLI on
+ * this machine (or `SKILLS_CLI` pointing at its entry file) and fails soft when
+ * that is missing, so CI never touches the network — nothing here is installed
+ * on demand:
+ *
+ * - nothing to run → prints "skipped: ..." and exits 0
+ * - a rejected id → exits 1, naming the host and the target
+ *
+ * Env:
+ * SKILLS_CLI path to the upstream CLI entry file (run with this Node)
+ */
+
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { AGENTS } from '../src/hosts/agents.mjs';
+
+const TIMEOUT_MS = 30_000;
+const SKIP_MESSAGE = 'skipped: upstream skills CLI unavailable';
+
+/** @returns {{command: string, prefix: string[], version: string}|null} */
+function resolveUpstreamCli() {
+ const candidates = [];
+
+ if (process.env.SKILLS_CLI) {
+ candidates.push({ command: process.execPath, prefix: [process.env.SKILLS_CLI] });
+ }
+
+ candidates.push({ command: 'skills', prefix: [] });
+ // `--no-install`: use an already-installed copy only, never the network.
+ candidates.push({ command: 'npx', prefix: ['--no-install', 'skills'] });
+
+ for (const candidate of candidates) {
+ const probe = spawnSync(candidate.command, [...candidate.prefix, '--version'], {
+ encoding: 'utf8',
+ timeout: TIMEOUT_MS,
+ });
+ if (probe.error || probe.status !== 0) continue;
+ const version = (probe.stdout || '').trim().split('\n').pop() || 'unknown';
+ return { ...candidate, version };
+ }
+
+ return null;
+}
+
+function main() {
+ const cli = resolveUpstreamCli();
+ if (!cli) {
+ console.log(SKIP_MESSAGE);
+ return 0;
+ }
+
+ console.log(`upstream skills CLI: ${cli.command} ${[...cli.prefix, `v${cli.version}`].join(' ')}`);
+
+ const sandboxHome = mkdtempSync(join(tmpdir(), 'distilly-agent-ids-'));
+ const env = {
+ ...process.env,
+ HOME: sandboxHome,
+ DISABLE_TELEMETRY: '1',
+ DO_NOT_TRACK: '1',
+ };
+
+ const cloneOnly = [];
+ const invalid = [];
+
+ try {
+ for (const agent of AGENTS) {
+ if (!agent.cliId) {
+ cloneOnly.push(agent.id);
+ continue;
+ }
+
+ const result = spawnSync(cli.command, [...cli.prefix, 'ls', '-a', agent.cliId], {
+ encoding: 'utf8',
+ env,
+ timeout: TIMEOUT_MS,
+ });
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
+ const rejected = /Invalid agents:/i.test(output) || result.status !== 0;
+
+ if (rejected) {
+ invalid.push(agent);
+ console.log(`FAIL ${agent.id.padEnd(18)} --agent ${agent.cliId}`);
+ } else {
+ console.log(`ok ${agent.id.padEnd(18)} --agent ${agent.cliId}`);
+ }
+ }
+ } finally {
+ rmSync(sandboxHome, { recursive: true, force: true });
+ }
+
+ console.log(
+ `checked ${AGENTS.length - cloneOnly.length} cliId targets: ` +
+ `${AGENTS.length - cloneOnly.length - invalid.length} valid, ${invalid.length} invalid`,
+ );
+ if (cloneOnly.length > 0) {
+ console.log(`clone-only (no upstream target): ${cloneOnly.join(', ')}`);
+ }
+
+ if (invalid.length > 0) {
+ console.error('');
+ console.error('The upstream CLI rejected these targets; update src/hosts/agents.mjs:');
+ for (const agent of invalid) {
+ console.error(` ${agent.id}: cliId "${agent.cliId}" -> run \`npx skills ls -a \` to find the new name`);
+ }
+ return 1;
+ }
+
+ return 0;
+}
+
+process.exit(main());
diff --git a/scripts/check_release.mjs b/scripts/check_release.mjs
new file mode 100644
index 00000000..7ac374c4
--- /dev/null
+++ b/scripts/check_release.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+/**
+ * Release readiness for this package: the things a version bump must not forget.
+ *
+ * `acceptance.mjs` proves the pipeline works and `audit-objective.mjs` proves the
+ * scope is closed; neither notices that the version printed by `--version` drifted
+ * from `package.json`, that a migrated ledger lost its schema marker, or that a
+ * `.py` file came back. Those are release-time questions, so they live here.
+ *
+ * node scripts/check_release.mjs [--json] [--tag vX.Y.Z]
+ *
+ * Exit code 1 when any check fails. `--tag` additionally asserts that the release
+ * tag names the same version the package declares.
+ */
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import { SCHEMA_VERSION } from "../src/skill/schema.mjs";
+import { LEDGER_SCHEMA_VERSION } from "../src/knowledge/ledger.mjs";
+
+const root = resolve(import.meta.dirname, "..");
+const json = process.argv.includes("--json");
+const tagIndex = process.argv.indexOf("--tag");
+const tag = tagIndex === -1 ? null : process.argv[tagIndex + 1];
+
+const rows = [];
+const record = (name, ok, evidence) => rows.push({ name, ok: Boolean(ok), evidence });
+
+const read = (relative) => readFileSync(join(root, relative), "utf8");
+const pkg = JSON.parse(read("package.json"));
+
+/* 1 — one version, everywhere it is printed ---------------------------------- */
+{
+ const binVersion = execFileSync(process.execPath, [join(root, "bin", "distilly.mjs"), "--version"], { encoding: "utf8" }).trim();
+ const skill = existsSync(join(root, "SKILL.md")) ? read("SKILL.md") : "";
+ const skillVersion = /^version:\s*"?([^"\n]+)"?/m.exec(skill)?.[1]?.trim() ?? null;
+ const consistent = binVersion === pkg.version && (skillVersion === null || skillVersion === pkg.version);
+ record(
+ "版本一致(package.json / --version / SKILL.md)",
+ consistent,
+ `package.json ${pkg.version}, --version ${binVersion}, SKILL.md ${skillVersion ?? "(未声明)"}`,
+ );
+ if (tag !== null) {
+ record("发布 tag 指向同一版本", tag === `v${pkg.version}` || tag === pkg.version, `--tag ${tag} vs ${pkg.version}`);
+ }
+}
+
+/* 2 — schemas are the frozen ones, and a migration exists -------------------- */
+{
+ const migrate = existsSync(join(root, "src", "skill", "migrate.mjs"));
+ const migrationTest = read(join("tests", "schema-migration.test.mjs"));
+ record(
+ `schema v${SCHEMA_VERSION} 与账本 v${LEDGER_SCHEMA_VERSION},迁移脚本在`,
+ SCHEMA_VERSION === "4" && migrate && /idempot/i.test(migrationTest),
+ `SCHEMA_VERSION=${SCHEMA_VERSION}, LEDGER_SCHEMA_VERSION=${LEDGER_SCHEMA_VERSION}, src/skill/migrate.mjs=${migrate}, 幂等断言=${/idempot/i.test(migrationTest)}`,
+ );
+}
+
+/* 3 — the installers still carry the evidence spine -------------------------- */
+{
+ const hosts = read(join("src", "install", "hosts.mjs"));
+ const carried = ["knowledge/raw", "knowledge/text", "evidence", "views"].every((name) => hosts.includes(name));
+ record(
+ "安装器携带 evidence spine(knowledge/raw、knowledge/text、evidence、views)",
+ carried,
+ carried ? "CARRIED_DIRECTORIES 覆盖四项" : "CARRIED_DIRECTORIES 缺项",
+ );
+}
+
+/* 4 — the package is zero-dependency and Python-free ------------------------- */
+{
+ const dependencies = Object.keys(pkg.dependencies ?? {});
+ const tracked = execFileSync("git", ["ls-files"], { cwd: root, encoding: "utf8" }).split("\n");
+ const python = tracked.filter((path) => /\.py$/.test(path) || /(^|\/)requirements\.txt$/.test(path));
+ record(
+ "零运行时依赖且没有 Python 残留",
+ dependencies.length === 0 && python.length === 0,
+ `dependencies: ${dependencies.length === 0 ? "none" : dependencies.join(", ")}; tracked .py / requirements.txt: ${python.length}`,
+ );
+}
+
+/* 5 — generated artefacts are in sync with their sources -------------------- */
+{
+ const template = execFileSync(process.execPath, [join(root, "scripts", "generate-template.mjs"), "--check"], { encoding: "utf8" }).trim();
+ const pinyin = existsSync(join(root, "assets", "pinyin.json"));
+ record(
+ "生成物与源同步(模板 / 拼音表)",
+ /up to date/.test(template) && pinyin,
+ `${template.split("\n").pop()}; assets/pinyin.json: ${pinyin}`,
+ );
+}
+
+/* 6 — the gates a release claims are runnable -------------------------------- */
+{
+ const gates = ["scripts/acceptance.mjs", "scripts/audit-objective.mjs", "scripts/prompt-lint.mjs", "scripts/visual-check.mjs", "scripts/split-corpus.mjs", "scripts/blind-test.mjs"];
+ const missing = gates.filter((path) => !existsSync(join(root, path)));
+ const ci = read(join(".github", "workflows", "ci.yml"));
+ const wired = ["node --test", "acceptance.mjs", "prompt-lint.mjs", "audit-objective.mjs"].every((needle) => ci.includes(needle));
+ record(
+ "发布所依赖的门禁都在,且 CI 会跑",
+ missing.length === 0 && wired,
+ `missing: ${missing.length === 0 ? "none" : missing.join(", ")}; CI 覆盖 node --test / acceptance / prompt-lint / audit: ${wired}`,
+ );
+}
+
+/* 7 — documentation a release points at ------------------------------------- */
+{
+ const docs = ["docs/v2/CONTRACT.md", "docs/v2/ACCEPTANCE.md", "docs/v2/STATUS.md", "docs/v2/MIGRATION.md", "docs/v2/IDENTITY.md", "README.md"];
+ const missing = docs.filter((path) => !existsSync(join(root, path)));
+ record("发布指向的文档都在", missing.length === 0, missing.length === 0 ? `${docs.length} 份文档就位` : `缺: ${missing.join(", ")}`);
+}
+
+const failed = rows.filter((row) => !row.ok);
+if (json) {
+ console.log(JSON.stringify({ ok: failed.length === 0, version: pkg.version, schema: SCHEMA_VERSION, rows }, null, 2));
+} else {
+ console.log("发布检查 / release check\n");
+ for (const row of rows) console.log(`${row.ok ? "✅" : "❌"} ${row.name}\n ${row.evidence}`);
+ console.log(`\n${rows.length - failed.length}/${rows.length} 项通过${failed.length === 0 ? "" : `;未通过:${failed.map((row) => row.name).join(";")}`}`);
+}
+process.exit(failed.length === 0 ? 0 : 1);
+
diff --git a/scripts/generate-pinyin.mjs b/scripts/generate-pinyin.mjs
new file mode 100644
index 00000000..a7d32e2e
--- /dev/null
+++ b/scripts/generate-pinyin.mjs
@@ -0,0 +1,204 @@
+#!/usr/bin/env node
+/**
+ * Derive `assets/pinyin.json` from the Unicode Unihan database.
+ *
+ * `pypinyin` was the writer's only optional Python dependency. The Node core
+ * ships a small, reviewable table instead, built from a single upstream source
+ * with a documented selection rule:
+ *
+ * characters = the 3500 most frequent Han characters according to the summed
+ * `kHanyuPinlu` corpus frequency in Unihan_Readings.txt; the reading is
+ * `kMandarin` (first reading) or, failing that, the highest-frequency
+ * `kHanyuPinlu` reading.
+ *
+ * Usage:
+ * node scripts/generate-pinyin.mjs # download Unihan.zip, extract, write the asset
+ * node scripts/generate-pinyin.mjs --source # use a local Unihan_Readings.txt
+ * node scripts/generate-pinyin.mjs --check # fail when the committed asset is stale
+ * node scripts/generate-pinyin.mjs --limit 0 # keep every covered character
+ *
+ * The output is deterministic (code-point order, no wall-clock timestamps): the
+ * `source_date` comes from the Unihan file header, so `--check` is a real drift
+ * gate and can run in CI.
+ */
+
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, "..");
+const DEFAULT_OUT = join(repoRoot, "assets", "pinyin.json");
+const DEFAULT_LIMIT = 3500;
+const UNIHAN_URL = "https://www.unicode.org/Public/UCD/latest/ucd/Unihan.zip";
+const UNICODE_LICENSE_URL = "https://www.unicode.org/license.txt";
+
+function arg(name, fallback) {
+ const index = process.argv.indexOf(`--${name}`);
+ return index === -1 ? fallback : process.argv[index + 1];
+}
+const has = (name) => process.argv.includes(`--${name}`);
+
+const outPath = resolve(arg("out", DEFAULT_OUT));
+const limit = Number.parseInt(arg("limit", String(DEFAULT_LIMIT)), 10);
+const checkOnly = has("check");
+const sourceArg = arg("source", null);
+
+/** Download Unihan.zip and extract Unihan_Readings.txt with the system unzip. */
+function fetchReadings() {
+ const dir = mkdtempSync(join(tmpdir(), "dst-unihan-"));
+ const zipPath = join(dir, "Unihan.zip");
+ console.log(`downloading ${UNIHAN_URL}`);
+ const download = spawnSync("curl", ["-fsSL", "-o", zipPath, UNIHAN_URL], { encoding: "utf8" });
+ if (download.status !== 0) {
+ throw new Error(
+ `cannot download Unihan.zip (${download.stderr?.trim() || "curl failed"}).\n` +
+ "Download it manually and pass --source .",
+ );
+ }
+ const unzip = spawnSync("unzip", ["-o", "-q", zipPath, "Unihan_Readings.txt", "-d", dir], {
+ encoding: "utf8",
+ });
+ if (unzip.status !== 0) {
+ throw new Error(
+ `cannot extract Unihan_Readings.txt (${unzip.stderr?.trim() || "unzip failed"}).\n` +
+ "Extract it manually and pass --source .",
+ );
+ }
+ return { path: join(dir, "Unihan_Readings.txt"), cleanup: () => rmSync(dir, { recursive: true, force: true }) };
+}
+
+/** Parse Unihan_Readings.txt into per-character frequency and reading data. */
+export function parseUnihan(text) {
+ const sourceDate = /^#\s*Date:\s*(.+?)\s*$/m.exec(text)?.[1] ?? null;
+ const unicodeVersion = /^#\s*Unicode Version\s*(.+?)\s*$/m.exec(text)?.[1] ?? null;
+
+ const frequencies = new Map();
+ const readings = new Map();
+
+ for (const line of text.split("\n")) {
+ if (line.startsWith("#") || line.trim() === "") continue;
+ const [codeField, field, value] = line.split("\t");
+ if (!codeField?.startsWith("U+") || !value) continue;
+ const character = String.fromCodePoint(Number.parseInt(codeField.slice(2), 16));
+
+ if (field === "kHanyuPinlu") {
+ let total = 0;
+ const entries = [];
+ for (const match of value.matchAll(/([A-Za-z\u00C0-\u024F\u0300-\u036F]+)\((\d+)\)/g)) {
+ const count = Number.parseInt(match[2], 10);
+ total += count;
+ entries.push({ reading: match[1], count });
+ }
+ if (entries.length > 0) frequencies.set(character, { total, entries });
+ } else if (field === "kMandarin") {
+ const primary = value.trim().split(/\s+/)[0];
+ if (primary) readings.set(character, primary);
+ }
+ }
+
+ return { sourceDate, unicodeVersion, frequencies, readings };
+}
+
+/** Build the asset object: top-N by frequency, code-point ordered, with provenance. */
+export function buildAsset({ sourceDate, unicodeVersion, frequencies, readings }, maxCharacters) {
+ const ranked = [...frequencies.entries()]
+ .map(([character, data]) => ({ character, total: data.total, entries: data.entries }))
+ .sort((left, right) => {
+ if (right.total !== left.total) return right.total - left.total;
+ return left.character.codePointAt(0) - right.character.codePointAt(0);
+ });
+
+ // Characters that carry a reading but no corpus frequency. They are real, common
+ // characters too — dropping them made the table cover 2404 of the 3500 it
+ // promised, and every one of the missing went to the explicit-slug fallback.
+ const unranked = [...readings.keys()]
+ .filter((character) => !frequencies.has(character))
+ .sort((left, right) => left.codePointAt(0) - right.codePointAt(0))
+ .map((character) => ({ character, total: 0, entries: [{ reading: readings.get(character), count: 0 }] }));
+
+ const ordered = [...ranked, ...unranked];
+ const selected = maxCharacters > 0 ? ordered.slice(0, maxCharacters) : ordered;
+ const characters = {};
+ for (const entry of selected) {
+ const best =
+ readings.get(entry.character) ??
+ [...entry.entries].sort((left, right) => right.count - left.count)[0].reading;
+ characters[entry.character] = best;
+ }
+
+ return {
+ _comment:
+ "Derived pinyin table for slug generation. Generated file — run `node scripts/generate-pinyin.mjs` to refresh; `--check` fails on drift.",
+ source: {
+ database: "Unihan",
+ url: UNIHAN_URL,
+ file: "Unihan_Readings.txt",
+ fields: ["kMandarin", "kHanyuPinlu"],
+ unicode_version: unicodeVersion,
+ source_date: sourceDate,
+ },
+ license: {
+ name: "Unicode License v3",
+ url: UNICODE_LICENSE_URL,
+ notice:
+ "Unihan data is Copyright © Unicode, Inc. and distributed under the Unicode License v3; see the URL above for the full text.",
+ },
+ generated_by: "scripts/generate-pinyin.mjs",
+ selection: {
+ rule: "top N Han characters by summed kHanyuPinlu frequency; reading = first kMandarin, else the most frequent kHanyuPinlu reading",
+ limit: maxCharacters,
+ // Every character this table could have described, not just the frequency-ranked
+ // ones: "covered" is about the source, not about which slice we kept.
+ covered_characters: new Set([...frequencies.keys(), ...readings.keys()]).size,
+ },
+ count: Object.keys(characters).length,
+ characters,
+ };
+}
+
+function main() {
+ let source = sourceArg;
+ let cleanup = () => {};
+ if (!source) {
+ const fetched = fetchReadings();
+ source = fetched.path;
+ cleanup = fetched.cleanup;
+ } else if (!existsSync(source)) {
+ throw new Error(`--source does not exist: ${source}`);
+ }
+
+ const text = readFileSync(source, "utf8");
+ const parsed = parseUnihan(text);
+ const asset = buildAsset(parsed, Number.isNaN(limit) ? DEFAULT_LIMIT : limit);
+ const serialized = `${JSON.stringify(asset, null, 2)}\n`;
+ cleanup();
+
+ console.log(
+ `unihan ${asset.source.unicode_version} (${asset.source.source_date}): ` +
+ `${asset.selection.covered_characters} covered characters, ` +
+ `${asset.count} written (limit ${asset.selection.limit})`,
+ );
+
+ if (checkOnly) {
+ if (!existsSync(outPath)) throw new Error(`asset missing: ${outPath}`);
+ const current = readFileSync(outPath, "utf8");
+ if (current !== serialized) {
+ console.error(`DRIFT: ${outPath} does not match a fresh Unihan derivation.`);
+ console.error("Run `node scripts/generate-pinyin.mjs` and commit the result.");
+ process.exitCode = 1;
+ return;
+ }
+ console.log(`OK: ${outPath} matches the current Unihan derivation.`);
+ return;
+ }
+
+ writeFileSync(outPath, serialized, "utf8");
+ console.log(`wrote ${outPath} (${Buffer.byteLength(serialized)} bytes)`);
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
+ main();
+}
diff --git a/scripts/generate-template.mjs b/scripts/generate-template.mjs
new file mode 100644
index 00000000..965c72ed
--- /dev/null
+++ b/scripts/generate-template.mjs
@@ -0,0 +1,211 @@
+#!/usr/bin/env node
+/**
+ * Build assets/distilly-template.html from assets/template.source.html plus the
+ * viewer fragments in viewer/. The template is a generated artifact: never edit
+ * it by hand, edit a fragment and rerun this script.
+ *
+ * node scripts/generate-template.mjs # write the template
+ * node scripts/generate-template.mjs --check # exit 1 when the template drifted
+ *
+ * Zero runtime dependencies.
+ */
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const packageRoot = fileURLToPath(new URL("..", import.meta.url));
+
+export const SOURCE_PATH = join(packageRoot, "assets", "template.source.html");
+export const TEMPLATE_PATH = join(packageRoot, "assets", "distilly-template.html");
+
+/** Fragment registry: marker token -> viewer fragment file. Frozen on purpose. */
+export const FRAGMENTS = {
+ VIEWER_SECTIONS: "viewer/sections.js",
+ VIEWER_THEME: "viewer/theme.js",
+ VIEWER_EXPORT: "viewer/export.js",
+ VIEWER_FOCUS: "viewer/focus.js",
+};
+
+const DATA_TOKEN = "@@DISTILLY:VIEW_DATA@@";
+const CSP =
+ " ";
+
+const FORBIDDEN_FRAGMENT = [
+ { pattern: " element" },
+ { pattern: "", why: "comments cannot be nested inside an inline script" },
+ { pattern: /^\s*(?:import|export)\s/m, why: "fragments must be classic scripts, not modules" },
+ { pattern: "require(", why: "browser fragments cannot use CommonJS require" },
+ { pattern: "http://", why: "the page must be offline: no absolute URLs" },
+ { pattern: "https://", why: "the page must be offline: no absolute URLs" },
+ { pattern: "//cdn", why: "the page must be offline: no CDN references" },
+];
+
+export function sha256(text) {
+ return createHash("sha256").update(text, "utf8").digest("hex");
+}
+
+function fail(message) {
+ throw new Error(`generate-template: ${message}`);
+}
+
+function validateFragment(name, path, source) {
+ if (source.trim() === "") fail(`${name} (${path}) is empty`);
+ for (const rule of FORBIDDEN_FRAGMENT) {
+ const hit = typeof rule.pattern === "string" ? source.includes(rule.pattern) : rule.pattern.test(source);
+ if (hit) fail(`${name} (${path}) contains ${String(rule.pattern)} — ${rule.why}`);
+ }
+}
+
+/**
+ * Build the template in memory. Deterministic: same fragments in, same bytes out.
+ * @returns {{output: string, fragments: Array<{token: string, path: string, sha256: string, bytes: number}>}}
+ */
+export function buildTemplate() {
+ const source = readFileSync(SOURCE_PATH, "utf8");
+ const fragments = [];
+ let output = source;
+
+ for (const [token, relative] of Object.entries(FRAGMENTS)) {
+ const absolute = join(packageRoot, relative);
+ if (!existsSync(absolute)) fail(`fragment file is missing: ${relative}`);
+ const body = readFileSync(absolute, "utf8");
+ validateFragment(token, relative, body);
+
+ const marker = new RegExp(``, "g");
+ const occurrences = output.match(marker) || [];
+ if (occurrences.length !== 1) {
+ fail(`marker @@DISTILLY:${token}@@ must appear exactly once in template.source.html (found ${occurrences.length})`);
+ }
+ output = output.replace(marker, ``);
+ fragments.push({ token, path: relative, sha256: sha256(body), bytes: Buffer.byteLength(body, "utf8") });
+ }
+
+ const dataMarkers = output.split(DATA_TOKEN).length - 1;
+ if (dataMarkers !== 1) fail(`marker ${DATA_TOKEN} must appear exactly once in template.source.html (found ${dataMarkers})`);
+
+ // The view payload marker stays in the committed template: src/views/render.mjs is the
+ // only writer that replaces it. Without a payload the page shows its empty state.
+ const leftover = (output.replace(DATA_TOKEN, "")).match(/@@DISTILLY:[A-Z_]+@@/g);
+ if (leftover) fail(`unsubstituted markers remain: ${[...new Set(leftover)].join(", ")}`);
+
+ const header =
+ "\n`;
+ output = output.replace("\n", `\n${header}`);
+
+ validateOutput(output);
+ return { output, fragments };
+}
+
+function validateOutput(output) {
+ if (!output.includes(CSP)) fail("the generated template lost the frozen CSP meta tag");
+ if (!output.includes(' ')) fail("the generated template lost ");
+ if ((output.match(/ element");
+ const dataTag = '`)) {
+ fail(`the view data marker ${DATA_TOKEN} must be the whole payload placeholder`);
+ }
+ for (const pattern of ["http://", "https://", "src=\"//", "@import", "url(http"]) {
+ if (output.includes(pattern)) fail(`the generated template must stay offline but contains ${pattern}`);
+ }
+}
+
+function parseArgs(argv) {
+ const options = { check: false, json: false, quiet: false };
+ for (const arg of argv) {
+ if (arg === "--check") options.check = true;
+ else if (arg === "--json") options.json = true;
+ else if (arg === "--quiet") options.quiet = true;
+ else if (arg === "--help" || arg === "-h") options.help = true;
+ else fail(`unknown option: ${arg}`);
+ }
+ return options;
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ console.log(`Usage: node scripts/generate-template.mjs [--check] [--json] [--quiet]
+
+ (no flags) write assets/distilly-template.html from template.source.html + viewer/*.js
+ --check compare the committed template with a fresh build; exit 1 on drift
+ --json print a receipt instead of prose`);
+ return 0;
+ }
+
+ const { output, fragments } = buildTemplate();
+ const bytes = Buffer.byteLength(output, "utf8");
+ const digest = sha256(output);
+ const committed = existsSync(TEMPLATE_PATH) ? readFileSync(TEMPLATE_PATH, "utf8") : null;
+
+ if (options.check) {
+ const drift = committed !== output;
+ const receipt = {
+ command: "template --check",
+ ok: !drift,
+ template: { path: "assets/distilly-template.html", sha256: digest, bytes, committed_sha256: committed === null ? null : sha256(committed) },
+ fragments,
+ warnings: [],
+ supported_fixes: drift
+ ? ["run: node scripts/generate-template.mjs", "commit assets/distilly-template.html with the fragment change"]
+ : [],
+ };
+ if (options.json) console.log(JSON.stringify(receipt, null, 2));
+ else if (drift) {
+ console.error("template drift: assets/distilly-template.html is not the build of the current fragments.");
+ console.error(` built sha256 ${digest} (${bytes} bytes)`);
+ console.error(` on disk sha256 ${committed === null ? "missing" : sha256(committed)}`);
+ console.error(" fix: node scripts/generate-template.mjs && git add assets/distilly-template.html");
+ } else if (!options.quiet) {
+ console.log(`template is up to date: sha256 ${digest} (${bytes} bytes, ${fragments.length} fragments)`);
+ }
+ return drift ? 1 : 0;
+ }
+
+ if (committed === output) {
+ if (!options.quiet) console.log(`template unchanged: sha256 ${digest} (${bytes} bytes)`);
+ return 0;
+ }
+
+ writeFileSync(TEMPLATE_PATH, output, "utf8");
+ if (options.json) {
+ console.log(
+ JSON.stringify(
+ {
+ command: "template",
+ ok: true,
+ outputs: [{ path: "assets/distilly-template.html", sha256: digest, bytes }],
+ fragments,
+ warnings: [],
+ },
+ null,
+ 2,
+ ),
+ );
+ } else if (!options.quiet) {
+ console.log(`wrote assets/distilly-template.html: sha256 ${digest} (${bytes} bytes)`);
+ for (const entry of fragments) console.log(` ${entry.token} <- ${entry.path} (${entry.bytes} bytes)`);
+ }
+ return 0;
+}
+
+if (process.argv[1] && isMain()) {
+ try {
+ process.exitCode = main();
+ } catch (error) {
+ console.error(`Error: ${error.message}`);
+ process.exitCode = 1;
+ }
+}
+
+/** True when this file is the process entry point (/tmp vs /private/tmp safe). */
+function isMain() {
+ try {
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
+ } catch (error) {
+ return false;
+ }
+}
diff --git a/scripts/parity.mjs b/scripts/parity.mjs
new file mode 100644
index 00000000..5bdfbe36
--- /dev/null
+++ b/scripts/parity.mjs
@@ -0,0 +1,663 @@
+#!/usr/bin/env node
+/**
+ * Byte-parity harness: pinned Python implementation vs. the Node core.
+ *
+ * The Python original is exported from git (it is deleted on this branch), so
+ * the comparison always runs against the frozen reference rather than a
+ * leftover working copy:
+ *
+ * node scripts/parity.mjs [--rev ] [--python ] [--keep]
+ * [--report ]
+ *
+ * Default revision: `git merge-base HEAD dot-skill-test` (the pre-port tree).
+ * Everything is compared byte for byte:
+ * A. library level — create/update/list/version operations writing the six
+ * artifacts plus meta.json into identical sandboxes;
+ * B. CLI level — `python3 tools/*.py` vs `node bin/distilly.mjs …` stdout,
+ * stderr and exit codes for the same command lines;
+ * C. pure helpers — slugify / normalize_command_slug / patch merging.
+ *
+ * The clock is frozen through `DISTILLY_PARITY_NOW` so timestamps cannot mask a
+ * real difference; the Python driver patches `now_iso` to the same value, and
+ * archived-version mtimes are pinned before they are listed.
+ */
+
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, "..");
+const FROZEN_NOW = "2024-01-02T03:04:05.678901+00:00";
+const FROZEN_MTIME = Math.floor(Date.parse("2024-01-02T03:04:05Z") / 1000);
+
+function arg(name, fallback) {
+ const index = process.argv.indexOf(`--${name}`);
+ return index === -1 ? fallback : process.argv[index + 1];
+}
+const has = (name) => process.argv.includes(`--${name}`);
+
+const keep = has("keep");
+const pythonExe = arg("python", process.env.PARITY_PYTHON ?? "python3");
+const reportPath = arg("report", null);
+
+function git(args) {
+ const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr ?? result.stdout}`);
+ }
+ return result.stdout.trim();
+}
+
+const rev = arg("rev", null) ?? process.env.PARITY_PY_REV ?? git(["merge-base", "HEAD", "dot-skill-test"]);
+
+const results = [];
+let failures = 0;
+
+function record(section, name, ok, detail = "") {
+ results.push({ section, name, ok, detail });
+ if (!ok) failures += 1;
+ console.log(` ${ok ? "PASS" : "DIFF"} ${section} :: ${name}${detail ? ` — ${detail}` : ""}`);
+}
+
+function sha256(buffer) {
+ return createHash("sha256").update(buffer).digest("hex");
+}
+
+function walk(root, base = root, into = new Map()) {
+ for (const entry of readdirSync(root).sort()) {
+ const full = join(root, entry);
+ if (statSync(full).isDirectory()) walk(full, base, into);
+ else into.set(relative(base, full), sha256(readFileSync(full)));
+ }
+ return into;
+}
+
+function compareTrees(label, leftRoot, rightRoot) {
+ const left = existsSync(leftRoot) ? walk(leftRoot) : new Map();
+ const right = existsSync(rightRoot) ? walk(rightRoot) : new Map();
+ const names = [...new Set([...left.keys(), ...right.keys()])].sort();
+ const differences = [];
+ for (const name of names) {
+ const a = left.get(name);
+ const b = right.get(name);
+ if (a === b) continue;
+ differences.push(`${name} [${a ? a.slice(0, 10) : "missing"} vs ${b ? b.slice(0, 10) : "missing"}]`);
+ }
+ record(
+ label,
+ `${names.length} files byte-identical`,
+ differences.length === 0,
+ differences.slice(0, 5).join("; ") + (differences.length > 5 ? ` (+${differences.length - 5} more)` : ""),
+ );
+ return { total: names.length, differences };
+}
+
+function pinMtimes(root, epochSeconds = FROZEN_MTIME) {
+ if (!existsSync(root)) return;
+ for (const entry of readdirSync(root)) {
+ const full = join(root, entry);
+ if (statSync(full).isDirectory()) {
+ utimesSync(full, epochSeconds, epochSeconds);
+ pinMtimes(full, epochSeconds);
+ }
+ }
+}
+
+const sandbox = mkdtempSync(join(tmpdir(), "dst-parity-"));
+const pyRoot = join(sandbox, "python");
+const nodeRoot = join(sandbox, "node");
+mkdirSync(pyRoot, { recursive: true });
+mkdirSync(nodeRoot, { recursive: true });
+
+const archive = join(sandbox, "pinned-tools.tar");
+const archiveResult = spawnSync("git", ["archive", "--format=tar", "-o", archive, rev, "tools"], {
+ cwd: repoRoot,
+ encoding: "utf8",
+});
+if (archiveResult.status !== 0) throw new Error(`cannot export tools/ at ${rev}: ${archiveResult.stderr}`);
+const untar = spawnSync("tar", ["-xf", archive, "-C", pyRoot], { encoding: "utf8" });
+if (untar.status !== 0) throw new Error(`cannot extract ${archive}: ${untar.stderr}`);
+
+console.log(`parity: pinned rev ${rev}`);
+console.log(`parity: python ${pythonExe}`);
+console.log(`parity: sandbox ${sandbox}\n`);
+
+/* ------------------------------------------------------------------ *
+ * Shared scenario script. Both drivers implement exactly these steps *
+ * and write the same files; nothing is sorted output-only. *
+ * ------------------------------------------------------------------ */
+
+const PY_DRIVER = String.raw`
+import contextlib, io, json, os, sys
+from pathlib import Path
+
+TOOLS = Path(__file__).resolve().parent
+sys.path.insert(0, str(TOOLS))
+
+FIXED = os.environ["DISTILLY_PARITY_NOW"]
+import skill_schema, skill_writer, version_manager # noqa: E402
+
+skill_schema.now_iso = lambda: FIXED
+skill_writer.now_iso = lambda: FIXED
+version_manager.now_iso = lambda: FIXED
+
+OUT = Path(sys.argv[1]).resolve()
+OUT.mkdir(parents=True, exist_ok=True)
+REPORT = OUT.parent / "report"
+REPORT.mkdir(parents=True, exist_ok=True)
+
+
+def emit(name, value):
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
+ (REPORT / name).write_text(text, encoding="utf-8")
+
+
+def capture(fn, *args, **kwargs):
+ buffer = io.StringIO()
+ with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
+ try:
+ result = fn(*args, **kwargs)
+ except Exception as error:
+ print("EXC %s: %s" % (type(error).__name__, error))
+ result = None
+ return buffer.getvalue() + "\n<>" % (result,)
+
+
+def new_base(name, character):
+ base = OUT / name / "skills" / character
+ base.mkdir(parents=True, exist_ok=True)
+ return base
+
+
+WORK_BODY = "## mental models\n- First-principles reasoning\n- Skeptical framing\n\n## limitations\n- Avoids operational detail\n\nSources:\nhttps://example.com/a\nhttps://example.com/b\n"
+PERSONA_BODY = "## expression DNA\n- Sentence rhythm is clipped.\n- Uses metaphor when disagreeing.\n\n## honest boundaries\n- States what they do not know.\n"
+ZH_WORK = "## \u5de5\u4f5c\u80fd\u529b\u4f7f\u7528\u8bf4\u660e\n\n\u5f53\u7528\u6237\u8981\u6c42\u4f60\u5b8c\u6210\u4ee5\u4e0b\u4efb\u52a1\u65f6\uff0c\u4e25\u683c\u6309\u7167\u4e0a\u8ff0\u89c4\u8303\u6267\u884c\u3002\n\n\u5982\u679c\u88ab\u95ee\u5230\u804c\u8d23\u8303\u56f4\u5916\u7684\u95ee\u9898\uff0c\u4ee5\u8be5\u540c\u4e8b\u7684\u65b9\u5f0f\u56de\u5e94\uff08\u53c2\u89c1 Persona \u90e8\u5206\uff09\u3002\n"
+EN_WORK = "## Scope rule\n\nIf you are asked a question outside your recorded responsibilities, respond in this colleague's style (see the Persona section).\n\n## Persona naming note\n\nKeep this documentation sentence.\n"
+
+# s1: colleague with rich metadata
+base = new_base("s1", "colleague")
+skill_writer.create_skill(base, "eulalie", {
+ "character": "colleague",
+ "display_name": "Eulalie",
+ "classification": {"language": "en"},
+ "profile": {"company": "ByteDance", "level": "L2-1", "role": "Backend Engineer", "mbti": "INTJ"},
+ "tags": {"personality": ["direct", "data-driven"], "culture": ["byte-dance-style"]},
+ "knowledge_sources": ["manual-notes"],
+}, WORK_BODY, PERSONA_BODY)
+
+# s2: relationship, Chinese chrome
+base = new_base("s2", "relationship")
+skill_writer.create_skill(base, "mireille", {
+ "character": "relationship",
+ "name": "Mireille",
+ "classification": {"language": "zh-CN"},
+ "profile": {"role": "Designer"},
+}, ZH_WORK, PERSONA_BODY)
+
+# s3: celebrity with tags list and research dirs
+base = new_base("s3", "celebrity")
+skill_writer.create_skill(base, "zadie-smith", {
+ "character": "celebrity",
+ "name": "Zadie Smith",
+ "profile": {"identity": "Novelist", "known_for": "Essay and criticism"},
+ "tags": ["literature", "essay", "public-intellectual"],
+ "knowledge_sources": ["interview", "essay"],
+}, EN_WORK, PERSONA_BODY)
+
+# s4: celebrity, deep research profile, Chinese, string profile
+base = new_base("s4", "celebrity")
+skill_writer.create_skill(base, "xu-zhisheng", {
+ "character": "celebrity",
+ "research_profile": "budget-unfriendly",
+ "name": "Xu Zhisheng",
+ "classification": {"language": "zh-CN"},
+ "profile": "\u4e2d\u56fd\u8131\u53e3\u79c0\u6f14\u5458\u3002",
+}, ZH_WORK, PERSONA_BODY)
+
+# s5: legacy dot-skill identifiers survive
+base = new_base("s5", "colleague")
+skill_writer.create_skill(base, "legacy", {
+ "name": "Legacy",
+ "preset": "dot.colleague.v1",
+ "engine": {"name": "dot-skill"},
+ "generation": {"engine": "dot-skill"},
+ "artifacts": {
+ "combined_name": "colleague_legacy",
+ "work_name": "colleague_legacy_work",
+ "persona_name": "colleague_legacy_persona",
+ },
+}, "Work body\n", "Persona body\n")
+
+# s6: updates on s1
+skill_dir = OUT / "s1" / "skills" / "colleague" / "eulalie"
+emit("update-work-patch.txt", capture(skill_writer.update_skill, skill_dir, "## new evidence\n- Adds a later example.\n"))
+emit("update-correction.txt", capture(skill_writer.update_skill, skill_dir, None, None, {"scene": "disagreement", "wrong": "flatten disagreement", "correct": "surface it directly"}))
+emit("update-replace-sections.txt", capture(skill_writer.update_skill, skill_dir, "## mental models\n- Replaced wholesale\n", "## expression DNA\n- Rewritten section\n"))
+emit("update-multi-corrections.txt", capture(skill_writer.update_skill, skill_dir, None, None, {"persona_corrections": [
+ {"scene": "\u94fa\u9648\u5904\u5883\u65f6", "wrong": "\u4e00\u4e0a\u6765\u5c31\u4e0b\u5224\u65ad", "correct": "\u5148\u628a\u5904\u5883\u8bb2\u5f97\u5f88\u666e\u901a"},
+ {"scene": "\u8868\u8fbe\u7acb\u573a\u65f6", "wrong": "\u5199\u6210\u660e\u663e\u81ea\u5632\u578b", "correct": "\u548c\u89c2\u4f17\u4e00\u8d77\u627f\u8ba4"},
+]}))
+
+# s7: listing
+emit("list-s1.txt", skill_writer.list_skills(OUT / "s1" / "skills" / "colleague"))
+emit("list-missing.txt", skill_writer.list_skills(OUT / "nope" / "skills"))
+
+# s8: version flow
+emit("version-backup.txt", capture(version_manager.backup_current_version, skill_dir))
+emit("version-list-before.txt", version_manager.list_versions(skill_dir))
+emit("version-rollback-ok.txt", capture(version_manager.rollback, skill_dir, "v1"))
+emit("version-rollback-missing.txt", capture(version_manager.rollback, skill_dir, "v99"))
+emit("version-rollback-traversal.txt", capture(version_manager.rollback, skill_dir, "../v1"))
+emit("version-list-after.txt", version_manager.list_versions(skill_dir))
+emit("version-cleanup.txt", capture(version_manager.cleanup_old_versions, skill_dir, 2))
+emit("version-cleanup-again.txt", capture(version_manager.cleanup_old_versions, skill_dir, 10))
+
+# s9: helpers and slug behaviour
+for index, value in enumerate(["Zadie Smith", "\u00c9lodie", "A/B", " --A--B-- ", "!!!", "\u5468\u5947\u58a8"]):
+ emit("normalize-command-slug-%d.txt" % index, skill_schema.normalize_command_slug(value))
+emit("merge-append.txt", skill_writer.merge_markdown_patch("intro\n", "no headings here\n"))
+emit("merge-replace.txt", skill_writer.merge_markdown_patch("intro\n\n## A\n\nold\n\n## B\n\nkeep\n", "## A\n\nnew\n"))
+emit("merge-unknown-section.txt", skill_writer.merge_markdown_patch("intro\n\n## A\n\nold\n", "## Z\n\nadded\n"))
+emit("work-only-zh.txt", skill_writer.work_only_content(ZH_WORK, chinese=True))
+emit("work-only-en.txt", skill_writer.work_only_content(EN_WORK, chinese=False))
+emit("validate-segments.txt", [skill_schema.validate_path_segment(value) for value in ["Zadie Smith", "\u00c9lodie"]])
+emit("validate-rejects.txt", [
+ capture(skill_schema.validate_path_segment, value).strip()
+ for value in ["C:", "foo:bar", "CON", "nul.txt", "trailing.", "trailing ", "", ".."]
+])
+`;
+
+const NODE_DRIVER = String.raw`
+import { mkdirSync, writeFileSync, existsSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import * as skillSchema from "__REPO__/src/skill/schema.mjs";
+import * as skillWriter from "__REPO__/src/skill/writer.mjs";
+import * as versionManager from "__REPO__/src/skill/versions.mjs";
+
+const OUT = resolve(process.argv[2]);
+mkdirSync(OUT, { recursive: true });
+const REPORT = join(OUT, "..", "report");
+mkdirSync(REPORT, { recursive: true });
+
+const emit = (name, value) => {
+ const text =
+ typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ writeFileSync(join(REPORT, name), text, "utf8");
+};
+
+const capture = (fn, ...args) => {
+ const chunks = [];
+ const originalOut = process.stdout.write.bind(process.stdout);
+ const originalErr = process.stderr.write.bind(process.stderr);
+ let result;
+ const sink = (chunk) => {
+ chunks.push(String(chunk));
+ return true;
+ };
+ process.stdout.write = sink;
+ process.stderr.write = sink;
+ try {
+ result = fn(...args);
+ } catch (error) {
+ chunks.push("EXC " + error.name + ": " + error.message + "\n");
+ result = undefined;
+ } finally {
+ process.stdout.write = originalOut;
+ process.stderr.write = originalErr;
+ }
+ const printed = chunks.join("");
+ return printed + "\n<>";
+};
+
+function require_repr(value) {
+ if (value === undefined) return "None";
+ if (value === null) return "None";
+ if (typeof value === "boolean") return value ? "True" : "False";
+ if (typeof value === "number") return String(value);
+ return JSON.stringify(value);
+}
+
+const newBase = (name, character) => {
+ const base = join(OUT, name, "skills", character);
+ mkdirSync(base, { recursive: true });
+ return base;
+};
+
+const WORK_BODY = "## mental models\n- First-principles reasoning\n- Skeptical framing\n\n## limitations\n- Avoids operational detail\n\nSources:\nhttps://example.com/a\nhttps://example.com/b\n";
+const PERSONA_BODY = "## expression DNA\n- Sentence rhythm is clipped.\n- Uses metaphor when disagreeing.\n\n## honest boundaries\n- States what they do not know.\n";
+const ZH_WORK = "## \u5de5\u4f5c\u80fd\u529b\u4f7f\u7528\u8bf4\u660e\n\n\u5f53\u7528\u6237\u8981\u6c42\u4f60\u5b8c\u6210\u4ee5\u4e0b\u4efb\u52a1\u65f6\uff0c\u4e25\u683c\u6309\u7167\u4e0a\u8ff0\u89c4\u8303\u6267\u884c\u3002\n\n\u5982\u679c\u88ab\u95ee\u5230\u804c\u8d23\u8303\u56f4\u5916\u7684\u95ee\u9898\uff0c\u4ee5\u8be5\u540c\u4e8b\u7684\u65b9\u5f0f\u56de\u5e94\uff08\u53c2\u89c1 Persona \u90e8\u5206\uff09\u3002\n";
+const EN_WORK = "## Scope rule\n\nIf you are asked a question outside your recorded responsibilities, respond in this colleague's style (see the Persona section).\n\n## Persona naming note\n\nKeep this documentation sentence.\n";
+
+// s1
+let base = newBase("s1", "colleague");
+skillWriter.createSkill(base, "eulalie", {
+ character: "colleague",
+ display_name: "Eulalie",
+ classification: { language: "en" },
+ profile: { company: "ByteDance", level: "L2-1", role: "Backend Engineer", mbti: "INTJ" },
+ tags: { personality: ["direct", "data-driven"], culture: ["byte-dance-style"] },
+ knowledge_sources: ["manual-notes"],
+}, WORK_BODY, PERSONA_BODY);
+
+// s2
+base = newBase("s2", "relationship");
+skillWriter.createSkill(base, "mireille", {
+ character: "relationship",
+ name: "Mireille",
+ classification: { language: "zh-CN" },
+ profile: { role: "Designer" },
+}, ZH_WORK, PERSONA_BODY);
+
+// s3
+base = newBase("s3", "celebrity");
+skillWriter.createSkill(base, "zadie-smith", {
+ character: "celebrity",
+ name: "Zadie Smith",
+ profile: { identity: "Novelist", known_for: "Essay and criticism" },
+ tags: ["literature", "essay", "public-intellectual"],
+ knowledge_sources: ["interview", "essay"],
+}, EN_WORK, PERSONA_BODY);
+
+// s4
+base = newBase("s4", "celebrity");
+skillWriter.createSkill(base, "xu-zhisheng", {
+ character: "celebrity",
+ research_profile: "budget-unfriendly",
+ name: "Xu Zhisheng",
+ classification: { language: "zh-CN" },
+ profile: "\u4e2d\u56fd\u8131\u53e3\u79c0\u6f14\u5458\u3002",
+}, ZH_WORK, PERSONA_BODY);
+
+// s5
+base = newBase("s5", "colleague");
+skillWriter.createSkill(base, "legacy", {
+ name: "Legacy",
+ preset: "dot.colleague.v1",
+ engine: { name: "dot-skill" },
+ generation: { engine: "dot-skill" },
+ artifacts: {
+ combined_name: "colleague_legacy",
+ work_name: "colleague_legacy_work",
+ persona_name: "colleague_legacy_persona",
+ },
+}, "Work body\n", "Persona body\n");
+
+// s6
+const skillDir = join(OUT, "s1", "skills", "colleague", "eulalie");
+emit("update-work-patch.txt", capture(skillWriter.updateSkill, skillDir, "## new evidence\n- Adds a later example.\n"));
+emit("update-correction.txt", capture(skillWriter.updateSkill, skillDir, null, null, { scene: "disagreement", wrong: "flatten disagreement", correct: "surface it directly" }));
+emit("update-replace-sections.txt", capture(skillWriter.updateSkill, skillDir, "## mental models\n- Replaced wholesale\n", "## expression DNA\n- Rewritten section\n"));
+emit("update-multi-corrections.txt", capture(skillWriter.updateSkill, skillDir, null, null, { persona_corrections: [
+ { scene: "\u94fa\u9648\u5904\u5883\u65f6", wrong: "\u4e00\u4e0a\u6765\u5c31\u4e0b\u5224\u65ad", correct: "\u5148\u628a\u5904\u5883\u8bb2\u5f97\u5f88\u666e\u901a" },
+ { scene: "\u8868\u8fbe\u7acb\u573a\u65f6", wrong: "\u5199\u6210\u660e\u663e\u81ea\u5632\u578b", correct: "\u548c\u89c2\u4f17\u4e00\u8d77\u627f\u8ba4" },
+] }));
+
+// s7
+emit("list-s1.txt", skillWriter.listSkills(join(OUT, "s1", "skills", "colleague")));
+emit("list-missing.txt", skillWriter.listSkills(join(OUT, "nope", "skills")));
+
+// s8
+emit("version-backup.txt", capture(versionManager.backupCurrentVersion, skillDir));
+emit("version-list-before.txt", versionManager.listVersions(skillDir));
+emit("version-rollback-ok.txt", capture(versionManager.rollback, skillDir, "v1"));
+emit("version-rollback-missing.txt", capture(versionManager.rollback, skillDir, "v99"));
+emit("version-rollback-traversal.txt", capture(versionManager.rollback, skillDir, "../v1"));
+emit("version-list-after.txt", versionManager.listVersions(skillDir));
+emit("version-cleanup.txt", capture(versionManager.cleanupOldVersions, skillDir, 2));
+emit("version-cleanup-again.txt", capture(versionManager.cleanupOldVersions, skillDir, 10));
+
+// s9
+["Zadie Smith", "\u00c9lodie", "A/B", " --A--B-- ", "!!!", "\u5468\u5947\u58a8"].forEach((value, index) => {
+ emit("normalize-command-slug-" + index + ".txt", skillSchema.normalizeCommandSlug(value));
+});
+emit("merge-append.txt", skillWriter.mergeMarkdownPatch("intro\n", "no headings here\n"));
+emit("merge-replace.txt", skillWriter.mergeMarkdownPatch("intro\n\n## A\n\nold\n\n## B\n\nkeep\n", "## A\n\nnew\n"));
+emit("merge-unknown-section.txt", skillWriter.mergeMarkdownPatch("intro\n\n## A\n\nold\n", "## Z\n\nadded\n"));
+emit("work-only-zh.txt", skillWriter.workOnlyContent(ZH_WORK, { chinese: true }));
+emit("work-only-en.txt", skillWriter.workOnlyContent(EN_WORK, { chinese: false }));
+emit("validate-segments.txt", ["Zadie Smith", "\u00c9lodie"].map((value) => skillSchema.validatePathSegment(value)));
+emit("validate-rejects.txt", ["C:", "foo:bar", "CON", "nul.txt", "trailing.", "trailing ", "", ".."].map((value) =>
+ capture(skillSchema.validatePathSegment, value).trim(),
+));
+`;
+
+/* ---------------------------- phase A ---------------------------- */
+
+const pyDriverPath = join(pyRoot, "tools", "parity_driver.py");
+writeFileSync(pyDriverPath, PY_DRIVER, "utf8");
+
+const nodeDriverPath = join(nodeRoot, "parity_driver.mjs");
+writeFileSync(nodeDriverPath, NODE_DRIVER.replaceAll("__REPO__", repoRoot), "utf8");
+
+const env = { ...process.env, DISTILLY_PARITY_NOW: FROZEN_NOW, PYTHONDONTWRITEBYTECODE: "1" };
+
+const pythonRun = spawnSync(pythonExe, [pyDriverPath, join(pyRoot, "out")], {
+ cwd: pyRoot,
+ encoding: "utf8",
+ env,
+});
+if (pythonRun.status !== 0) {
+ console.error(pythonRun.stdout);
+ console.error(pythonRun.stderr);
+ throw new Error(`python driver failed with status ${pythonRun.status}`);
+}
+
+const nodeRun = spawnSync(process.execPath, [nodeDriverPath, join(nodeRoot, "out")], {
+ cwd: nodeRoot,
+ encoding: "utf8",
+ env,
+});
+if (nodeRun.status !== 0) {
+ console.error(nodeRun.stdout);
+ console.error(nodeRun.stderr);
+ throw new Error(`node driver failed with status ${nodeRun.status}`);
+}
+
+pinMtimes(join(pyRoot, "out"));
+pinMtimes(join(nodeRoot, "out"));
+
+compareTrees("A library", join(pyRoot, "out"), join(nodeRoot, "out"));
+
+/* ---------------------------- phase B ---------------------------- */
+
+const CLI_STEPS = [
+ {
+ name: "create",
+ python: ["tools/skill_writer.py", "--action", "create", "--character", "colleague", "--slug", "eulalie", "--name", "Eulalie", "--meta", "meta.json", "--work", "work.md", "--persona", "persona.md", "--skills-dir", "skills/colleague"],
+ node: ["skill", "create", "--character", "colleague", "--slug", "eulalie", "--name", "Eulalie", "--meta", "meta.json", "--work", "work.md", "--persona", "persona.md", "--skills-dir", "skills/colleague"],
+ },
+ {
+ name: "create-pinyin-name",
+ python: ["tools/skill_writer.py", "--action", "create", "--character", "colleague", "--name", "Zadie Smith", "--skills-dir", "skills/colleague"],
+ node: ["skill", "create", "--character", "colleague", "--name", "Zadie Smith", "--skills-dir", "skills/colleague"],
+ },
+ {
+ name: "list",
+ python: ["tools/skill_writer.py", "--action", "list", "--character", "colleague", "--skills-dir", "skills/colleague"],
+ node: ["skill", "list", "--character", "colleague", "--skills-dir", "skills/colleague"],
+ },
+ {
+ name: "update",
+ python: ["tools/skill_writer.py", "--action", "update", "--character", "colleague", "--slug", "eulalie", "--skills-dir", "skills/colleague", "--work-patch", "patch.md", "--correction-json", "correction.json"],
+ node: ["skill", "update", "--character", "colleague", "--slug", "eulalie", "--skills-dir", "skills/colleague", "--work-patch", "patch.md", "--correction-json", "correction.json"],
+ },
+ { name: "version-list", python: ["tools/version_manager.py", "--action", "list", "--slug", "eulalie", "--skills-dir", "skills/colleague"], node: ["skill", "version", "list", "--slug", "eulalie", "--skills-dir", "skills/colleague"] },
+ { name: "version-backup", python: ["tools/version_manager.py", "--action", "backup", "--slug", "eulalie", "--skills-dir", "skills/colleague"], node: ["skill", "version", "backup", "--slug", "eulalie", "--skills-dir", "skills/colleague"] },
+ { name: "version-rollback", python: ["tools/version_manager.py", "--action", "rollback", "--slug", "eulalie", "--version", "v1", "--skills-dir", "skills/colleague"], node: ["skill", "version", "rollback", "--slug", "eulalie", "--version", "v1", "--skills-dir", "skills/colleague"] },
+ { name: "version-cleanup", python: ["tools/version_manager.py", "--action", "cleanup", "--slug", "eulalie", "--skills-dir", "skills/colleague"], node: ["skill", "version", "cleanup", "--slug", "eulalie", "--skills-dir", "skills/colleague"] },
+];
+
+function seedCliSandbox(root) {
+ mkdirSync(join(root, "skills", "colleague"), { recursive: true });
+ writeFileSync(join(root, "meta.json"), JSON.stringify({ character: "colleague", display_name: "Eulalie", classification: { language: "en" }, profile: { role: "Backend Engineer" } }, null, 2), "utf8");
+ writeFileSync(join(root, "work.md"), "Work body\n", "utf8");
+ writeFileSync(join(root, "persona.md"), "Persona body\n", "utf8");
+ writeFileSync(join(root, "patch.md"), "## Update\n\nPatched section.\n", "utf8");
+ writeFileSync(join(root, "correction.json"), JSON.stringify({ scene: "review", wrong: "hedge", correct: "state the risk plainly" }), "utf8");
+}
+
+const cliPy = join(sandbox, "cli-python");
+const cliNode = join(sandbox, "cli-node");
+for (const root of [cliPy, cliNode]) {
+ rmSync(root, { recursive: true, force: true });
+ mkdirSync(root, { recursive: true });
+ seedCliSandbox(root);
+}
+
+const cliEnv = { ...env, DISTILLY_AUTO_INSTALL_CLAUDE: "0", DOT_SKILL_AUTO_INSTALL_CLAUDE: "0" };
+const cliTranscript = { python: [], node: [] };
+
+for (const step of CLI_STEPS) {
+ // Archive mtimes are minute-precision in the listing; pin them before listing.
+ if (step.name === "version-list" || step.name === "version-cleanup") {
+ pinMtimes(join(cliPy, "skills", "colleague", "eulalie", "versions"));
+ pinMtimes(join(cliNode, "skills", "colleague", "eulalie", "versions"));
+ }
+ const py = spawnSync(pythonExe, step.python, { cwd: cliPy, encoding: "utf8", env: cliEnv });
+ const js = spawnSync(process.execPath, [join(repoRoot, "bin", "distilly.mjs"), ...step.node], {
+ cwd: cliNode,
+ encoding: "utf8",
+ env: cliEnv,
+ });
+ const pyOut = `status=${py.status}\n--- stdout ---\n${py.stdout}--- stderr ---\n${py.stderr}`;
+ const jsOut = `status=${js.status}\n--- stdout ---\n${js.stdout}--- stderr ---\n${js.stderr}`;
+ cliTranscript.python.push(`### ${step.name}\n${pyOut}`);
+ cliTranscript.node.push(`### ${step.name}\n${jsOut}`);
+ record(
+ "B cli",
+ step.name,
+ pyOut === jsOut,
+ pyOut === jsOut ? "" : `exit ${py.status}/${js.status}; first diff at ${firstDifference(pyOut, jsOut)}`,
+ );
+}
+
+record(
+ "B cli",
+ "installed tree byte-identical",
+ JSON.stringify([...walk(join(cliPy, "skills")).keys()].sort()) ===
+ JSON.stringify([...walk(join(cliNode, "skills")).keys()].sort()),
+);
+compareTrees("B cli", join(cliPy, "skills"), join(cliNode, "skills"));
+
+function firstDifference(left, right) {
+ const limit = Math.min(left.length, right.length);
+ for (let index = 0; index < limit; index += 1) {
+ if (left[index] !== right[index]) {
+ return `char ${index}: ${JSON.stringify(left.slice(Math.max(0, index - 20), index + 20))} vs ${JSON.stringify(right.slice(Math.max(0, index - 20), index + 20))}`;
+ }
+ }
+ return left.length === right.length ? "identical" : `length ${left.length} vs ${right.length}`;
+}
+
+/* ---------------------------- phase C ---------------------------- */
+
+const slugNames = ["Zadie Smith", "\u00C9lodie", "A/B", "Zhou Qimo", "Mireille"];
+const pySlug = spawnSync(
+ pythonExe,
+ [
+ "-c",
+ [
+ "import sys, json",
+ `sys.path.insert(0, ${JSON.stringify(join(pyRoot, "tools"))})`,
+ "import skill_writer",
+ "names = json.loads(sys.argv[1])",
+ "out = {}",
+ "for name in names:",
+ " try:",
+ " out[name] = skill_writer.slugify(name)",
+ " except Exception as error:",
+ " out[name] = 'EXC ' + type(error).__name__",
+ "print(json.dumps(out, ensure_ascii=False, sort_keys=True))",
+ ].join("\n"),
+ JSON.stringify(slugNames),
+ ],
+ { cwd: pyRoot, encoding: "utf8", env },
+);
+
+let pypinyinAvailable = false;
+const pypinyinProbe = spawnSync(pythonExe, ["-c", "import pypinyin"], { encoding: "utf8", env });
+pypinyinAvailable = pypinyinProbe.status === 0;
+
+if (pySlug.status === 0) {
+ const pythonSlugs = JSON.parse(pySlug.stdout);
+ const { slugify } = await import(join(repoRoot, "src/skill/writer.mjs"));
+ const nodeSlugs = {};
+ for (const name of slugNames) {
+ try {
+ nodeSlugs[name] = slugify(name);
+ } catch (error) {
+ nodeSlugs[name] = `EXC ${error.name}`;
+ }
+ }
+ for (const name of slugNames) {
+ record(
+ "C slugify",
+ `${name} → ${pythonSlugs[name]}`,
+ pythonSlugs[name] === nodeSlugs[name] || !pypinyinAvailable,
+ pythonSlugs[name] === nodeSlugs[name]
+ ? ""
+ : `node: ${nodeSlugs[name]}${pypinyinAvailable ? "" : " (python ran without pypinyin)"}`,
+ );
+ }
+} else {
+ record("C slugify", "python slugify probe", false, pySlug.stderr.trim().split("\n")[0]);
+}
+
+/* ---------------------------- report ---------------------------- */
+
+const summary = {
+ rev,
+ python: pythonExe,
+ pypinyinAvailable,
+ frozenNow: FROZEN_NOW,
+ checks: results.length,
+ failures,
+ results,
+};
+
+if (reportPath) {
+ writeFileSync(
+ reportPath,
+ [
+ `# parity report`,
+ ``,
+ `- pinned rev: \`${rev}\``,
+ `- python: \`${pythonExe}\` (pypinyin available: ${pypinyinAvailable})`,
+ `- frozen clock: \`${FROZEN_NOW}\``,
+ `- checks: ${results.length}, failures: ${failures}`,
+ ``,
+ `| section | check | result | detail |`,
+ `| --- | --- | --- | --- |`,
+ ...results.map((r) => `| ${r.section} | ${r.name} | ${r.ok ? "OK" : "DIFF"} | ${r.detail.replaceAll("|", "\\|")} |`),
+ ``,
+ `Raw transcript (${keep ? sandbox : "sandbox removed"}):`,
+ ``,
+ "```text",
+ ...results.map((r) => `${r.ok ? "PASS" : "DIFF"} ${r.section} :: ${r.name} ${r.detail}`),
+ "```",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+}
+
+if (keep) console.log(`\nsandbox kept: ${sandbox}`);
+else rmSync(sandbox, { recursive: true, force: true });
+
+console.log(`\nparity: ${results.length - failures}/${results.length} checks passed`);
+process.exitCode = failures === 0 ? 0 : 1;
diff --git a/scripts/prompt-lint.mjs b/scripts/prompt-lint.mjs
new file mode 100644
index 00000000..594ba5a9
--- /dev/null
+++ b/scripts/prompt-lint.mjs
@@ -0,0 +1,362 @@
+#!/usr/bin/env node
+/**
+ * Prompt contract lint (zero dependencies).
+ *
+ * Scans SKILL.md and prompts/**.md and enforces the prompt-layer contract
+ * documented in docs/v2/PROMPTS.md:
+ *
+ * command every `distilly ` must exist in docs/v2/CONTRACT.md §1
+ * sections every prompt (and SKILL.md) has 必须/禁止/回执 + MUST/MUST NOT/RECEIPT
+ * bilingual a `## English` half exists and both halves name the same commands
+ * anchor anchors are [k00NN] / [k00NN:tM]
+ * forbidden no shell HTTP client, no Python HTTP library usage,
+ * no literal credential assignment, no `sk-` shaped key
+ * deprecated every tools/**\/*.py|sh reference is marked deprecated nearby
+ *
+ * Usage:
+ * node scripts/prompt-lint.mjs [--root ] [--json]
+ *
+ * Exit codes: 0 = clean, 1 = findings, 2 = the lint itself could not run
+ * (missing contract / unparsable command block) — never silently pass.
+ */
+
+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { join, relative, resolve, sep } from "node:path";
+
+const RULE_ORDER = [
+ "command",
+ "sections",
+ "bilingual",
+ "anchor",
+ "forbidden",
+ "deprecated",
+];
+
+const ANCHOR_TEMPLATES = new Set(["[k00NN]", "[k00NN:tM]"]);
+const ANCHOR_VALID = /^\[k\d{4}(:t\d+)?\]$/;
+const ANCHOR_CANDIDATE = /\[[kK][0-9:tNMT]+\]/g;
+const COMMAND_USE = /distilly[ \t]+([A-Za-z][A-Za-z0-9-]*)/g;
+
+const FORBIDDEN = [
+ {
+ name: "shell-http-client",
+ pattern: /\bcurl\b/i,
+ message: "shell HTTP client must not be used; collect through `distilly collect`",
+ },
+ {
+ name: "python-http-library",
+ pattern:
+ /\bimport\s+requests\b|\bfrom\s+requests\b|\brequests\.(get|post|put|patch|delete|head|options|request|Session)\b/,
+ message: "hand-written API calls are forbidden; use `distilly collect`",
+ },
+ {
+ name: "literal-credential",
+ pattern:
+ /(?:api[_-]?key|apikey|app[_-]?secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|password|passwd|secret|token)["'`]?\s*[:=]\s*["'`][^"'`\s]{3,}["'`]/i,
+ message: "a credential literal is written down; read it from ~/.distilly/*_config.json or env",
+ },
+ {
+ name: "key-prefix",
+ pattern:
+ /\bsk-(?:proj-|ant-|live-|test-|or-)?[A-Za-z0-9]{20,}\b|\bsk-(?:x{3,}|\.{3,}|<[^>]+>|\$\{[^}]+\}|YOUR|your|PLACEHOLDER|placeholder|redacted|REDACTED|abc|1234)/,
+ message: "a key-shaped literal is written down; never put credentials in prompts",
+ },
+];
+
+const ZERO_DEP_MARKER = "distilly";
+
+function fail(message) {
+ process.stderr.write(`prompt-lint: ${message}\n`);
+ process.exit(2);
+}
+
+function parseArgs(argv) {
+ const options = { root: null, json: false, help: false };
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--root") {
+ options.root = argv[index + 1];
+ if (!options.root) fail("--root requires a directory");
+ index += 1;
+ } else if (arg === "--json") {
+ options.json = true;
+ } else if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ } else {
+ fail(`unknown option: ${arg}`);
+ }
+ }
+ return options;
+}
+
+/** Command names come from the frozen contract, never from a second list. */
+export function parseContractCommands(contractText) {
+ const lines = contractText.split("\n");
+ const headingIndex = lines.findIndex((line) => /^##\s+1\./.test(line));
+ if (headingIndex < 0) return null;
+ let fenceStart = -1;
+ for (let index = headingIndex; index < lines.length; index += 1) {
+ if (/^```/.test(lines[index])) {
+ fenceStart = index;
+ break;
+ }
+ }
+ if (fenceStart < 0) return null;
+ let fenceEnd = -1;
+ for (let index = fenceStart + 1; index < lines.length; index += 1) {
+ if (/^```/.test(lines[index])) {
+ fenceEnd = index;
+ break;
+ }
+ }
+ if (fenceEnd < 0) return null;
+
+ const commands = new Set();
+ for (const raw of lines.slice(fenceStart + 1, fenceEnd)) {
+ const line = raw.trim();
+ if (line.length === 0 || line.startsWith("#")) continue;
+ const first = line.split(/\s+/)[0];
+ if (/^[a-z][a-z0-9-]*$/.test(first)) commands.add(first);
+ // `install | uninstall` and `view check | view render` keep the
+ // second bare word; angle-bracket groups are placeholders, not commands.
+ const withoutPlaceholders = line.replace(/<[^>]*>/g, " ");
+ for (const match of withoutPlaceholders.matchAll(/\|\s*([a-z][a-z0-9-]*)\b/g)) {
+ commands.add(match[1]);
+ }
+ }
+ return commands.size > 0 ? commands : null;
+}
+
+function englishMarkerIndex(lines) {
+ return lines.findIndex((line) => /^##\s+English\b/.test(line));
+}
+
+function headingMissing(text, marker) {
+ const pattern = new RegExp(`^#{2,3}\\s+${marker}\\s*$`, "m");
+ return !pattern.test(text);
+}
+
+export function lintText({ path, text, commands, isPrompt }) {
+ const findings = [];
+ const lines = text.split("\n");
+ const add = (line, rule, message) =>
+ findings.push({ file: path, line: line + 1, rule, message });
+
+ // --- command names -------------------------------------------------------
+ lines.forEach((line, index) => {
+ for (const match of line.matchAll(COMMAND_USE)) {
+ const command = match[1];
+ if (!commands.has(command)) {
+ add(
+ index,
+ "command",
+ `unknown command \`distilly ${command}\`; not in docs/v2/CONTRACT.md §1 (${[...commands].sort().join(", ")})`,
+ );
+ }
+ }
+ });
+
+ // --- forbidden tokens ----------------------------------------------------
+ lines.forEach((line, index) => {
+ for (const rule of FORBIDDEN) {
+ if (rule.pattern.test(line)) {
+ add(index, "forbidden", `${rule.name}: ${rule.message}`);
+ }
+ }
+ });
+
+ // --- anchors -------------------------------------------------------------
+ lines.forEach((line, index) => {
+ for (const match of line.matchAll(ANCHOR_CANDIDATE)) {
+ const token = match[0];
+ if (ANCHOR_VALID.test(token) || ANCHOR_TEMPLATES.has(token)) continue;
+ add(
+ index,
+ "anchor",
+ `malformed anchor ${token}; use [k00NN] or [k00NN:tM]`,
+ );
+ }
+ });
+
+ // --- deprecated legacy tooling ------------------------------------------
+ const nonEmpty = lines.map((line) => line.trim().length > 0);
+ lines.forEach((line, index) => {
+ if (!/tools\/[\w./-]+\.(py|sh)/.test(line)) return;
+ let start = index;
+ while (start > 0 && nonEmpty[start - 1]) start -= 1;
+ let end = index;
+ while (end < lines.length - 1 && nonEmpty[end + 1]) end += 1;
+ const paragraph = lines.slice(start, end + 1).join("\n");
+ if (!/deprecated/i.test(paragraph)) {
+ add(
+ index,
+ "deprecated",
+ "legacy tools/... reference is not marked deprecated in its paragraph",
+ );
+ }
+ });
+
+ // --- bilingual split -----------------------------------------------------
+ const marker = englishMarkerIndex(lines);
+ const zhText = marker < 0 ? text : lines.slice(0, marker).join("\n");
+ const enText = marker < 0 ? "" : lines.slice(marker).join("\n");
+ if (marker < 0) {
+ add(0, "bilingual", "missing `## English` section (中文段 → --- → ## English)");
+ } else {
+ const separator = lines
+ .slice(Math.max(0, marker - 4), marker)
+ .some((line) => /^---\s*$/.test(line));
+ if (!separator) {
+ add(marker, "bilingual", "`## English` must be preceded by a `---` separator");
+ }
+ const zhCommands = new Set(
+ [...zhText.matchAll(COMMAND_USE)].map((match) => match[1]),
+ );
+ const enCommands = new Set(
+ [...enText.matchAll(COMMAND_USE)].map((match) => match[1]),
+ );
+ for (const command of [...zhCommands].sort()) {
+ if (!enCommands.has(command)) {
+ add(
+ marker,
+ "bilingual",
+ `command \`distilly ${command}\` appears only in the Chinese half`,
+ );
+ }
+ }
+ for (const command of [...enCommands].sort()) {
+ if (!zhCommands.has(command)) {
+ add(
+ marker,
+ "bilingual",
+ `command \`distilly ${command}\` appears only in the English half`,
+ );
+ }
+ }
+ }
+
+ // --- required sections ---------------------------------------------------
+ if (isPrompt || path === "SKILL.md") {
+ const at = marker < 0 ? 0 : marker;
+ for (const markerText of ["必须", "禁止", "回执"]) {
+ if (headingMissing(zhText, markerText)) {
+ add(at, "sections", `missing Chinese \`## ${markerText}\` section`);
+ }
+ }
+ for (const markerText of ["MUST", "MUST NOT", "RECEIPT"]) {
+ if (headingMissing(enText, markerText)) {
+ add(at, "sections", `missing English \`## ${markerText}\` section`);
+ }
+ }
+ }
+
+ return findings;
+}
+
+function collectTargets(root) {
+ const targets = [];
+ const skill = join(root, "SKILL.md");
+ if (existsSync(skill)) targets.push({ path: "SKILL.md", isPrompt: false });
+
+ const promptsRoot = join(root, "prompts");
+ const walk = (dir) => {
+ if (!existsSync(dir)) return;
+ for (const entry of readdirSync(dir).sort()) {
+ const full = join(dir, entry);
+ const stats = statSync(full);
+ if (stats.isDirectory()) {
+ walk(full);
+ } else if (entry.endsWith(".md")) {
+ targets.push({
+ path: relative(root, full).split(sep).join("/"),
+ isPrompt: true,
+ });
+ }
+ }
+ };
+ walk(promptsRoot);
+ return targets;
+}
+
+export function runLint(root) {
+ const contractPath = join(root, "docs", "v2", "CONTRACT.md");
+ if (!existsSync(contractPath)) {
+ fail(`contract not found at ${contractPath}`);
+ }
+ const commands = parseContractCommands(readFileSync(contractPath, "utf8"));
+ if (!commands) {
+ fail(`could not parse the §1 command block from ${contractPath}`);
+ }
+
+ const targets = collectTargets(root);
+ if (targets.length === 0) {
+ fail(`no SKILL.md or prompts/**/*.md found under ${root}`);
+ }
+
+ const findings = [];
+ for (const target of targets) {
+ const text = readFileSync(join(root, target.path), "utf8");
+ findings.push(
+ ...lintText({ path: target.path, text, commands, isPrompt: target.isPrompt }),
+ );
+ }
+
+ findings.sort(
+ (left, right) =>
+ left.file.localeCompare(right.file) ||
+ left.line - right.line ||
+ RULE_ORDER.indexOf(left.rule) - RULE_ORDER.indexOf(right.rule) ||
+ left.message.localeCompare(right.message),
+ );
+
+ return {
+ commands: [...commands].sort(),
+ files: targets.map((target) => target.path),
+ findings,
+ };
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ process.stdout.write(
+ [
+ "prompt-lint — prompt contract lint (zero dependencies)",
+ "",
+ "Usage: node scripts/prompt-lint.mjs [--root ] [--json]",
+ "",
+ `Contract commands: parsed from docs/v2/CONTRACT.md §1 at run time (${ZERO_DEP_MARKER}).`,
+ "",
+ ].join("\n"),
+ );
+ return;
+ }
+
+ const root = resolve(
+ options.root ?? fileURLToPath(new URL("..", import.meta.url)),
+ );
+ const result = runLint(root);
+
+ if (options.json) {
+ process.stdout.write(
+ `${JSON.stringify({ root, ok: result.findings.length === 0, ...result }, null, 2)}\n`,
+ );
+ } else {
+ for (const finding of result.findings) {
+ process.stdout.write(
+ `${finding.file}:${finding.line} ${finding.rule} ${finding.message}\n`,
+ );
+ }
+ const filesWithFindings = new Set(result.findings.map((f) => f.file)).size;
+ process.stdout.write(
+ `prompt-lint: ${result.findings.length} finding(s) in ${filesWithFindings} file(s) across ${result.files.length} file(s) scanned (contract commands: ${result.commands.length})\n`,
+ );
+ }
+
+ if (result.findings.length > 0) process.exit(1);
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/scripts/skill-artifacts.mjs b/scripts/skill-artifacts.mjs
new file mode 100644
index 00000000..ec197d01
--- /dev/null
+++ b/scripts/skill-artifacts.mjs
@@ -0,0 +1,89 @@
+/**
+ * The mechanical checks an acceptance run applies to a **generated Skill**.
+ *
+ * They live in their own module, and take plain strings, so they can be falsified by
+ * a unit test: a gate nobody has ever seen go red is not a gate. That was the state
+ * of this repo until now — the end-to-end acceptance covered harvest → retrospect →
+ * view → render and never touched `skill create`, so the artifact this product
+ * actually delivers could be missing, or could contradict itself, with every gate
+ * green.
+ *
+ * The three failure modes these checks exist for, all observed for real:
+ *
+ * - `SKILL.md` absent, or missing PART A / PART B / its operating rules;
+ * - a persona without the Layer 0–5 structure `prompts/persona_builder.md` defines —
+ * the shipped operating rules promise "Layer 0 rules always take priority" while
+ * nothing generated or checked that a Layer 0 existed;
+ * - an anchor the artifact cites that the ledger does not declare (a citation that
+ * cannot be followed back to the corpus, which is the product's whole claim).
+ *
+ * Distillation *quality* is deliberately not here: it needs a judge, and lives in the
+ * effect layer (`scripts/blind-test.mjs`).
+ */
+
+/** The artifact set `distilly skill create` promises for a character family. */
+export const REQUIRED_ARTIFACTS = [
+ 'SKILL.md',
+ 'work.md',
+ 'persona.md',
+ 'work_skill.md',
+ 'persona_skill.md',
+ 'manifest.json',
+ 'meta.json',
+];
+
+/** Sections the assembled `SKILL.md` must carry. */
+export const REQUIRED_SECTIONS = ['PART A', 'PART B', 'Operating Rules'];
+
+/** Layer headings the persona builder defines. */
+export const REQUIRED_LAYERS = [0, 1, 2, 3, 4, 5].map((index) => `Layer ${index}`);
+
+/**
+ * Whether `body` carries `Layer N` as a **heading**.
+ *
+ * Not a substring test: the operating rules that ship with every generated Skill
+ * contain the sentence "Layer 0 rules in PART B always take priority", so
+ * `body.includes("Layer 0")` is true even when PART B has no Layer 0 at all — which
+ * is exactly the shape that shipped green. Requiring `##` makes the promise text
+ * unable to satisfy the check it promises.
+ */
+function hasLayerHeading(body, index) {
+ return new RegExp(`^##\\s*Layer\\s*${index}\\b`, "m").test(body);
+}
+
+/** Anchors an artifact cites, as `k00NN` / `k00NN:tM`, deduplicated. */
+export function citedAnchors(body) {
+ const found = new Set();
+ for (const match of String(body ?? '').matchAll(/\[(k\d{4}(?::t\d+)?)\]/g)) found.add(match[1]);
+ return found;
+}
+
+/**
+ * Inspect a generated Skill.
+ *
+ * @param {Record} files artifact name → body (`null` when absent)
+ * @param {Set} knownAnchors anchors the ledger declares
+ */
+export function inspectSkillArtifacts(files, knownAnchors = new Set()) {
+ const missingArtifacts = REQUIRED_ARTIFACTS.filter((name) => files[name] === undefined || files[name] === null);
+
+ const skillBody = files['SKILL.md'] ?? '';
+ const missingSections = REQUIRED_SECTIONS.filter((section) => !skillBody.includes(section));
+ const missingLayers = REQUIRED_LAYERS.filter((_layer, index) => !hasLayerHeading(skillBody, index));
+
+ // Layer 0 must say something: the heading alone is what a non-compliant persona
+ // produces when the builder prompt is skipped, and it is exactly the shape that
+ // shipped once already.
+ const layer0Body = skillBody.split('## Layer 0')[1]?.split('## Layer 1')[0] ?? '';
+ const layer0Rules = layer0Body
+ .split('\n')
+ .filter((row) => row.trim().startsWith('- ') && row.trim().length > 4).length;
+
+ const cited = new Set();
+ for (const name of ['SKILL.md', 'work.md', 'persona.md']) {
+ for (const anchor of citedAnchors(files[name])) cited.add(anchor);
+ }
+ const dangling = [...cited].filter((anchor) => !knownAnchors.has(anchor));
+
+ return { missingArtifacts, missingSections, missingLayers, layer0Rules, cited: [...cited], dangling };
+}
diff --git a/scripts/split-corpus.mjs b/scripts/split-corpus.mjs
new file mode 100644
index 00000000..9c7fe1d4
--- /dev/null
+++ b/scripts/split-corpus.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+/**
+ * Split a corpus by time into the A (distillation) and B (held-out) halves that
+ * `docs/v2/ACCEPTANCE.md` requires, and write a receipt so the split cannot be
+ * quietly re-rolled after seeing the results.
+ *
+ * node scripts/split-corpus.mjs --in --out [--ratio 0.7]
+ *
+ * Subtitles are split on the cue timeline (B starts where A stops). Anything else
+ * is split by paragraph count, and the receipt records `by: "paragraphs"` so the
+ * weaker guarantee is visible.
+ */
+
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { basename, extname, join } from "node:path";
+
+import { SourceFile } from "../src/parse/common.mjs";
+import { detectSubtitleFormat, formatTimecode, splitCues } from "../src/parse/subtitle.mjs";
+
+function arg(name, fallback) {
+ const index = process.argv.indexOf(`--${name}`);
+ return index === -1 ? fallback : process.argv[index + 1];
+}
+
+const input = arg("in");
+const outDir = arg("out");
+const ratio = Number(arg("ratio", "0.7"));
+if (!input || !outDir) {
+ console.error("usage: node scripts/split-corpus.mjs --in --out [--ratio 0.7]");
+ process.exit(2);
+}
+if (!(ratio > 0.1 && ratio < 0.95)) {
+ console.error(`ratio must be between 0.1 and 0.95 (got ${ratio})`);
+ process.exit(2);
+}
+
+const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
+const extension = extname(input).toLowerCase();
+const raw = readFileSync(input);
+const source = new SourceFile({ path: basename(input), raw });
+
+mkdirSync(outDir, { recursive: true });
+
+let by;
+let aText;
+let bText;
+let aMeta;
+let bMeta;
+let cut = null;
+
+if (extension === ".srt" || extension === ".vtt") {
+ const { format } = detectSubtitleFormat(source);
+ const { cues } = splitCues(source, format);
+ if (cues.length < 4) throw new Error(`need at least four cues to split ${input}`);
+ const total = cues[cues.length - 1].end;
+ const cutAt = total * ratio;
+ let boundary = cues.findIndex((cue) => cue.start >= cutAt);
+ if (boundary < 2) boundary = 2;
+ if (boundary > cues.length - 2) boundary = cues.length - 2;
+
+ const render = (list) =>
+ list
+ .map((cue, index) => {
+ const body = cue.speaker ? `${cue.speaker}:${cue.text}` : cue.text;
+ return `${index + 1}\n${formatTimecode(cue.start)} --> ${formatTimecode(cue.end)}\n${body}\n`;
+ })
+ .join("\n");
+
+ const a = cues.slice(0, boundary);
+ const b = cues.slice(boundary);
+ aText = render(a);
+ bText = render(b);
+ // The vocabulary is "timecode" everywhere else (`cut.timecode`, the subtitle
+ // parser's `meta.timecodes`), so the receipt says the same thing.
+ by = "timecode";
+ cut = {
+ index: boundary,
+ timecode: formatTimecode(cues[boundary].start),
+ a_end_timecode: formatTimecode(a[a.length - 1].end),
+ };
+ aMeta = { cues: a.length, start: formatTimecode(a[0].start), end: formatTimecode(a[a.length - 1].end) };
+ bMeta = { cues: b.length, start: formatTimecode(b[0].start), end: formatTimecode(b[b.length - 1].end) };
+} else {
+ const paragraphs = source.text.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
+ if (paragraphs.length < 4) throw new Error(`need at least four paragraphs to split ${input}`);
+ const boundary = Math.min(Math.max(Math.round(paragraphs.length * ratio), 1), paragraphs.length - 1);
+ aText = `${paragraphs.slice(0, boundary).join("\n\n")}\n`;
+ bText = `${paragraphs.slice(boundary).join("\n\n")}\n`;
+ by = "paragraphs";
+ cut = { index: boundary, timecode: null, a_end_timecode: null };
+ aMeta = { paragraphs: boundary };
+ bMeta = { paragraphs: paragraphs.length - boundary };
+}
+
+const aPath = join(outDir, `A${extension || ".txt"}`);
+const bPath = join(outDir, `B${extension || ".txt"}`);
+writeFileSync(aPath, aText, "utf8");
+writeFileSync(bPath, bText, "utf8");
+
+const receipt = {
+ source: input,
+ split_by: by,
+ ratio,
+ cut,
+ a: { file: basename(aPath), sha256: sha256(aText), bytes: Buffer.byteLength(aText), ...aMeta },
+ b: { file: basename(bPath), sha256: sha256(bText), bytes: Buffer.byteLength(bText), ...bMeta },
+};
+writeFileSync(join(outDir, "split.json"), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
+
+console.log(`split (${by}, ratio ${ratio}) → ${aPath} / ${bPath}`);
+console.log(` A: ${JSON.stringify(aMeta)}`);
+console.log(` B: ${JSON.stringify(bMeta)}`);
+console.log(" keep B away from the distiller and the judge; only the checker reads it.");
diff --git a/scripts/visual-check.mjs b/scripts/visual-check.mjs
new file mode 100644
index 00000000..21048c75
--- /dev/null
+++ b/scripts/visual-check.mjs
@@ -0,0 +1,542 @@
+#!/usr/bin/env node
+/**
+ * Is this anchor's outcome a problem?
+ *
+ * Every appendix row must exist, be focusable and be visible; only an anchor the
+ * prose cites must also carry a back-link. Pure so it can be unit-tested without a
+ * browser.
+ */
+export function anchorProblem(outcome, cited) {
+ if (!outcome || outcome.ok !== true) return true;
+ if (outcome.inAppendix !== true) return true;
+ if (outcome.focused !== true) return true;
+ if (outcome.visible !== true) return true;
+ if (cited === true && !(outcome.backLinks >= 1)) return true;
+ return false;
+}
+
+/**
+ * distilly visual-check — open a rendered view page in Chrome and assert the
+ * eight visual contracts from docs/v2/CONTRACT.md §4:
+ *
+ * 1 console is silent (no error/warning, no pageerror, no failed request)
+ * 2 the eight page segments exist and are non-empty
+ * 3 no horizontal overflow (1280 / 768 / 375 px)
+ * 4 dual-theme contrast spot checks (system preference + manual toggle)
+ * 5 every evidence anchor resolves to a focusable row in the appendix
+ * 6 zero network requests, CSP present, no external reference
+ * 7 @media print does not clip or drop content
+ * 8 PNG evidence is written to --out
+ *
+ * playwright is a DEVELOPMENT dependency and is never imported by the runtime:
+ * when it is missing this script fails loudly with install guidance.
+ *
+ * node scripts/visual-check.mjs views/.html [--out ] [--json]
+ */
+import { isEntryPoint } from "../src/cli/entry.mjs";
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const ROOT = fileURLToPath(new URL("..", import.meta.url));
+const DEFAULT_OUT = "/tmp/dst-evidence/pr-03";
+const RESULTS = [];
+
+const SAMPLE_SELECTORS = [
+ "#page-title",
+ ".claim__text",
+ ".claim__meta",
+ ".anchor-ref",
+ ".badge",
+ ".warning__text",
+ ".evidence__anchor",
+];
+
+function parseArgs(argv) {
+ const options = { html: null, out: DEFAULT_OUT, json: false };
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--json") options.json = true;
+ else if (arg === "--out" || arg === "--out-dir") {
+ const value = argv[index + 1];
+ if (!value) throw new Error(`${arg} requires a directory`);
+ options.out = value;
+ index += 1;
+ } else if (arg === "--help" || arg === "-h") options.help = true;
+ else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
+ else if (!options.html) options.html = arg;
+ else throw new Error(`unexpected argument: ${arg}`);
+ }
+ return options;
+}
+
+function usage() {
+ console.log(`Usage: node scripts/visual-check.mjs [--out ] [--json]
+
+ a page produced by: distilly view render
+ --out PNG output directory (default ${DEFAULT_OUT}; never committed)
+ --json print the machine-readable result
+
+Exit code 0 only when all eight checks pass.`);
+}
+
+/** playwright is a dev dependency: resolve it from the usual places, else fail loudly. */
+async function loadChromium() {
+ const roots = [process.env.DISTILLY_PLAYWRIGHT_ROOT, ROOT, process.cwd()].filter(Boolean);
+ for (const root of roots) {
+ try {
+ const require = createRequire(join(root, "index.cjs"));
+ const resolved = require.resolve("playwright");
+ const mod = await import(pathToFileURL(resolved).href);
+ const chromium = mod.chromium ?? mod.default?.chromium;
+ if (chromium) return chromium;
+ } catch (error) {
+ /* try the next root */
+ }
+ }
+ try {
+ const mod = await import("playwright");
+ const chromium = mod.chromium ?? mod.default?.chromium;
+ if (chromium) return chromium;
+ } catch (error) {
+ /* fall through to the loud failure below */
+ }
+ console.error("Error: the visual check needs playwright, which is a development dependency.");
+ console.error(" npm install --no-save playwright # or: pnpm add -D playwright");
+ console.error(" DISTILLY_PLAYWRIGHT_ROOT= node scripts/visual-check.mjs ");
+ console.error(" distilly itself has zero runtime dependencies; nothing else needs playwright.");
+ process.exit(2);
+}
+
+async function launch(chromium) {
+ try {
+ return await chromium.launch({ channel: "chrome" });
+ } catch (error) {
+ return chromium.launch();
+ }
+}
+
+function record(id, name, ok, detail) {
+ RESULTS.push({ id, name, ok: Boolean(ok), detail });
+ return Boolean(ok);
+}
+
+/** Contrast of a node against its nearest opaque ancestor background, WCAG 2.x ratio. */
+function contrastProbe(selectors) {
+ const parse = (value) => {
+ const match = /rgba?\(([^)]+)\)/.exec(value || "");
+ if (!match) return null;
+ const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
+ };
+ const luminance = ({ r, g, b }) => {
+ const channel = (value) => {
+ const scaled = value / 255;
+ return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+ };
+ const background = (node) => {
+ let current = node;
+ while (current && current.nodeType === 1) {
+ const colour = parse(getComputedStyle(current).backgroundColor);
+ if (colour && colour.a > 0.5) return colour;
+ current = current.parentElement;
+ }
+ return { r: 255, g: 255, b: 255, a: 1 };
+ };
+ const ratio = (a, b) => {
+ const first = luminance(a);
+ const second = luminance(b);
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
+ };
+
+ const samples = [];
+ for (const selector of selectors) {
+ const node = document.querySelector(selector);
+ if (!node) {
+ samples.push({ selector, missing: true });
+ continue;
+ }
+ const style = getComputedStyle(node);
+ const foreground = parse(style.color);
+ const behind = background(node);
+ samples.push({
+ selector,
+ fontSize: Number.parseFloat(style.fontSize),
+ ratio: foreground ? Number(ratio(foreground, behind).toFixed(2)) : null,
+ foreground: style.color,
+ background: `rgb(${behind.r}, ${behind.g}, ${behind.b})`,
+ });
+ }
+ return { theme: document.documentElement.getAttribute("data-theme-effective"), samples };
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ usage();
+ return 0;
+ }
+ if (!options.html) {
+ usage();
+ return 2;
+ }
+ const htmlPath = resolve(options.html);
+ if (!existsSync(htmlPath)) {
+ console.error(`Error: rendered page not found: ${htmlPath}`);
+ console.error(" fix: distilly view render (or pass the path of an existing views/.html)");
+ return 2;
+ }
+ const outDir = resolve(options.out);
+ mkdirSync(outDir, { recursive: true });
+ const url = pathToFileURL(htmlPath).href;
+ const ready = () => page.waitForFunction(() => document.documentElement.getAttribute("data-view-ready") === "true", null, { timeout: 15000 });
+
+ const chromium = await loadChromium();
+ const browser = await launch(chromium);
+ const context = await browser.newContext({
+ viewport: { width: 1280, height: 900 },
+ deviceScaleFactor: 1,
+ colorScheme: "light",
+ });
+ const page = await context.newPage();
+
+ const consoleMessages = [];
+ const pageErrors = [];
+ const failedRequests = [];
+ const requests = [];
+ page.on("console", (message) => {
+ if (message.type() === "error" || message.type() === "warning") {
+ consoleMessages.push({ type: message.type(), text: message.text() });
+ }
+ });
+ page.on("pageerror", (error) => pageErrors.push(String(error && error.message ? error.message : error)));
+ page.on("requestfailed", (request) => failedRequests.push({ url: request.url(), error: request.failure()?.errorText ?? null }));
+ page.on("request", (request) => requests.push({ url: request.url(), type: request.resourceType() }));
+
+ const pngs = [];
+ const screenshot = async (name) => {
+ const file = join(outDir, name);
+ await page.screenshot({ path: file, fullPage: true });
+ pngs.push({ file, bytes: statSync(file).size });
+ };
+
+ try {
+ await page.goto(url, { waitUntil: "load" });
+ await ready();
+
+ /* 1 — console silence ------------------------------------------------ */
+ record(
+ "console",
+ "console has no error/warning, no page error, no failed request",
+ consoleMessages.length === 0 && pageErrors.length === 0 && failedRequests.length === 0,
+ { messages: consoleMessages, pageErrors, failedRequests },
+ );
+
+ /* 2 — eight non-empty segments --------------------------------------- */
+ const segments = await page.evaluate(() => {
+ const rows = [...document.querySelectorAll("[data-section]")].map((node) => ({
+ id: node.getAttribute("data-section"),
+ chars: (node.textContent || "").trim().length,
+ items: node.querySelectorAll(".claim, .warning, .timeline__item, .evidence").length,
+ }));
+ const view = window.DistillyView || {};
+ return {
+ rows,
+ payloadAnchors: view.view && Array.isArray(view.view.evidence) ? view.view.evidence.length : 0,
+ appendixAnchors: document.querySelectorAll('[data-section="evidence"] .evidence[data-anchor]').length,
+ shareable: Boolean(view.shareable),
+ quotesRendered: document.querySelectorAll(".quote[data-inlined]").length,
+ };
+ });
+ const emptySegments = segments.rows.filter((entry) => entry.chars < 8);
+ record(
+ "segments",
+ "the eight page segments exist and are non-empty",
+ segments.rows.length === 8 && emptySegments.length === 0 && segments.appendixAnchors > 0,
+ {
+ count: segments.rows.length,
+ empty: emptySegments.map((entry) => entry.id),
+ rows: segments.rows,
+ shareable: segments.shareable,
+ quotesRendered: segments.quotesRendered,
+ },
+ );
+
+ /* 3 — no horizontal overflow ---------------------------------------- */
+ const overflow = [];
+ for (const width of [1280, 768, 375]) {
+ await page.setViewportSize({ width, height: 900 });
+ const measured = await page.evaluate(() => {
+ const limit = window.innerWidth + 1;
+ const offenders = [];
+ for (const node of document.querySelectorAll("body *")) {
+ const rect = node.getBoundingClientRect();
+ if (rect.width > 0 && rect.right > limit) {
+ offenders.push({
+ tag: node.tagName.toLowerCase(),
+ cls: String(node.className || "").slice(0, 60),
+ right: Math.round(rect.right),
+ });
+ }
+ }
+ return { delta: document.documentElement.scrollWidth - window.innerWidth, offenders: offenders.slice(0, 5) };
+ });
+ overflow.push({ width, delta: measured.delta, offenders: measured.offenders });
+ }
+ await page.setViewportSize({ width: 1280, height: 900 });
+ record(
+ "overflow",
+ "no horizontal overflow at 1280/768/375 px",
+ overflow.every((entry) => entry.delta <= 1),
+ overflow,
+ );
+
+ /* 4 — dual theme contrast ------------------------------------------- */
+ const themeRuns = [];
+ // `emulateMedia` resolves as soon as the emulation is applied; the page learns
+ // about it through a `matchMedia` change event and updates
+ // `data-theme-effective` a tick later. Probing immediately recorded the
+ // *previous* theme — which is how this gate went red roughly one run in two
+ // with byte-identical HTML (same sha256), and why the toggle then saw
+ // `before: "dark"` after being put back into light mode.
+ const settle = (scheme) =>
+ page
+ .waitForFunction(
+ (expected) => {
+ const current = document.documentElement.getAttribute("data-theme-effective");
+ return current === null || current === expected;
+ },
+ scheme,
+ { timeout: 2000 },
+ )
+ .catch(() => {});
+ await page.emulateMedia({ colorScheme: "light" });
+ await settle("light");
+ themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS));
+ await page.emulateMedia({ colorScheme: "dark" });
+ await settle("dark");
+ themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS));
+ await page.emulateMedia({ colorScheme: "light" });
+ await settle("light");
+ const toggle = await page.evaluate(() => {
+ const button = document.getElementById("theme-toggle");
+ if (!button) return { ok: false, reason: "no #theme-toggle button" };
+ const before = document.documentElement.getAttribute("data-theme-effective");
+ button.click();
+ return { ok: null, before, after: null, pressed: button.getAttribute("aria-pressed"), label: button.textContent };
+ });
+ // The click is handled by the page, so its effect is also a tick away.
+ toggle.after = await page
+ .waitForFunction(
+ (before) => document.documentElement.getAttribute("data-theme-effective") !== before,
+ toggle.before,
+ { timeout: 2000 },
+ )
+ .then(() => page.evaluate(() => document.documentElement.getAttribute("data-theme-effective")))
+ .catch(() => page.evaluate(() => document.documentElement.getAttribute("data-theme-effective")));
+ toggle.pressed = await page.evaluate(() => document.getElementById("theme-toggle")?.getAttribute("aria-pressed") ?? null);
+ toggle.ok = toggle.before === "light" && toggle.after === "dark";
+ themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS));
+ const contrastFailures = [];
+ for (const run of themeRuns) {
+ for (const sample of run.samples) {
+ if (sample.missing) contrastFailures.push({ ...sample, theme: run.theme, reason: "sample element missing" });
+ else if (sample.ratio !== null && sample.ratio < 4.5 && sample.fontSize < 24) {
+ contrastFailures.push({ ...sample, theme: run.theme, reason: "contrast below 4.5:1" });
+ }
+ }
+ }
+ record(
+ "theme",
+ "dual theme (system + manual) with >= 4.5:1 contrast samples",
+ contrastFailures.length === 0 && toggle.ok === true && new Set(themeRuns.map((run) => run.theme)).size >= 2,
+ { runs: themeRuns, toggle, failures: contrastFailures },
+ );
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.evaluate(() => {
+ const button = document.getElementById("theme-toggle");
+ if (button && document.documentElement.getAttribute("data-theme-effective") === "dark") button.click();
+ });
+
+ /* 5 — anchors resolve into the appendix ------------------------------ */
+ const anchorIds = await page.evaluate(() =>
+ [...document.querySelectorAll('[data-section="evidence"] .evidence[data-anchor]')].map((node) => node.getAttribute("data-anchor")),
+ );
+ const anchorProblems = [];
+ for (const anchor of anchorIds) {
+ await page.evaluate((id) => {
+ window.location.hash = `#anchor-${id}`;
+ }, anchor);
+ await page.waitForTimeout(40);
+ const outcome = await page.evaluate((id) => {
+ const node = document.getElementById(`anchor-${id}`);
+ if (!node) return { id, ok: false, reason: "no element with that id" };
+ const rect = node.getBoundingClientRect();
+ return {
+ id,
+ ok: true,
+ inAppendix: Boolean(node.closest('[data-section="evidence"]')),
+ focused: document.activeElement === node,
+ visible: rect.top < window.innerHeight && rect.bottom > 0,
+ backLinks: node.querySelectorAll('a[href^="#section-"]').length,
+ };
+ }, anchor);
+ if (!outcome.ok || !outcome.inAppendix || !outcome.focused || !outcome.visible || outcome.backLinks === 0) {
+ anchorProblems.push(outcome);
+ }
+ }
+ await page.evaluate(() => {
+ try {
+ window.history.replaceState(null, "", window.location.pathname);
+ } catch (error) {
+ window.location.hash = "";
+ }
+ });
+ record(
+ "anchors",
+ "each evidence anchor locates a focusable row in the appendix",
+ anchorIds.length > 0 && anchorIds.length === segments.payloadAnchors && anchorProblems.length === 0,
+ { anchors: anchorIds.length, payloadAnchors: segments.payloadAnchors, problems: anchorProblems },
+ );
+
+ /* 6 — zero network requests ----------------------------------------- */
+ const staticRefs = await page.evaluate(() => {
+ const csp = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
+ return {
+ csp: csp ? csp.getAttribute("content") : null,
+ externalLinks: document.querySelectorAll('link[href]:not([href^="data:"])').length,
+ externalScripts: document.querySelectorAll("script[src]").length,
+ externalImages: document.querySelectorAll('img[src]:not([src^="data:"])').length,
+ embeds: document.querySelectorAll("iframe, object, embed").length,
+ urls: (document.documentElement.outerHTML.match(/https?:\/\/[^\s"'<>]+/g) || []).filter(
+ (value) => !value.includes("www.w3.org"),
+ ),
+ };
+ });
+ const externalRequests = requests.filter(
+ (entry) => !entry.url.startsWith("file:") && !entry.url.startsWith("data:") && !entry.url.startsWith("blob:"),
+ );
+ record(
+ "offline",
+ "zero network requests, frozen CSP present, no external reference",
+ externalRequests.length === 0 &&
+ Boolean(staticRefs.csp && staticRefs.csp.includes("default-src 'none'")) &&
+ staticRefs.externalLinks === 0 &&
+ staticRefs.externalScripts === 0 &&
+ staticRefs.externalImages === 0 &&
+ staticRefs.embeds === 0 &&
+ staticRefs.urls.length === 0,
+ { requests: requests.length, externalRequests, staticRefs },
+ );
+
+ /* 7 — print media does not clip -------------------------------------- */
+ await page.emulateMedia({ media: "print" });
+ const printReport = await page.evaluate(() => {
+ const nodes = [...document.querySelectorAll("[data-section]")];
+ const clipped = [];
+ let sectionText = 0;
+ for (const node of nodes) {
+ const style = getComputedStyle(node);
+ sectionText += (node.textContent || "").length;
+ if (style.display === "none" || style.visibility === "hidden") {
+ clipped.push({ id: node.getAttribute("data-section"), reason: "hidden in print" });
+ continue;
+ }
+ if (node.scrollWidth > node.clientWidth + 2) {
+ clipped.push({ id: node.getAttribute("data-section"), reason: "horizontal clip", scrollWidth: node.scrollWidth, clientWidth: node.clientWidth });
+ }
+ if (node.scrollHeight > node.clientHeight + 2) {
+ clipped.push({ id: node.getAttribute("data-section"), reason: "vertical clip", scrollHeight: node.scrollHeight, clientHeight: node.clientHeight });
+ }
+ }
+ return { segments: nodes.length, clipped, sectionText, overflow: document.documentElement.scrollWidth - window.innerWidth };
+ });
+ await screenshot("view-print.png");
+ await page.emulateMedia({ media: "screen" });
+ const screenReport = await page.evaluate(() => {
+ const nodes = [...document.querySelectorAll("[data-section]")];
+ let sectionText = 0;
+ for (const node of nodes) sectionText += (node.textContent || "").length;
+ return { sectionText, segments: nodes.length };
+ });
+ record(
+ "print",
+ "@media print does not clip or drop content",
+ printReport.clipped.length === 0 &&
+ printReport.segments === 8 &&
+ printReport.overflow <= 1 &&
+ printReport.sectionText === screenReport.sectionText,
+ { ...printReport, screenSectionText: screenReport.sectionText, screenSegments: screenReport.segments },
+ );
+
+ /* 8 — PNG evidence --------------------------------------------------- */
+ await page.goto(`${url}?theme=light`, { waitUntil: "load" });
+ await ready();
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await screenshot("view-light.png");
+ await page.goto(`${url}?theme=dark`, { waitUntil: "load" });
+ await ready();
+ await screenshot("view-dark.png");
+ // Three PNGs: print, light, dark. The 375 px capture was dropped on request —
+ // the narrow-viewport *check* stays (it is check 3, at 1280/768/375), so a
+ // layout that breaks on a phone still fails; only the picture goes away.
+ record(
+ "png",
+ "PNG evidence written to the output directory (print + light + dark)",
+ pngs.length >= 3 && pngs.every((entry) => entry.bytes > 1024),
+ { outDir, pngs },
+ );
+
+ const failed = RESULTS.filter((entry) => !entry.ok);
+ const payload = {
+ command: "visual-check",
+ ok: failed.length === 0,
+ html: htmlPath,
+ html_sha256: createHash("sha256").update(readFileSync(htmlPath)).digest("hex"),
+ html_bytes: statSync(htmlPath).size,
+ out_dir: outDir,
+ shareable: segments.shareable,
+ appendix_anchors: segments.appendixAnchors,
+ checks: RESULTS,
+ pngs,
+ failed: failed.map((entry) => entry.id),
+ checks_passed: RESULTS.length - failed.length,
+ checks_total: RESULTS.length,
+ };
+
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
+ else {
+ for (const entry of RESULTS) {
+ console.log(`${entry.ok ? "PASS" : "FAIL"} ${entry.id.padEnd(9)} ${entry.name}`);
+ if (!entry.ok) console.log(` detail: ${JSON.stringify(entry.detail)}`);
+ }
+ console.log(` input: ${htmlPath} (${payload.html_bytes} bytes, sha256 ${payload.html_sha256})`);
+ }
+ console.log(
+ failed.length === 0
+ ? `visual-check: PASS — ${RESULTS.length}/8 checks, PNGs in ${outDir} (${pngs.map((entry) => entry.file.split("/").pop()).join(", ")})`
+ : `visual-check: FAIL — ${failed.length}/${RESULTS.length} checks failed: ${failed.map((entry) => entry.id).join(", ")}`,
+ );
+ return failed.length === 0 ? 0 : 1;
+ } finally {
+ await context.close();
+ await browser.close();
+ }
+}
+
+// Only run when invoked directly. `tests/visual-check-rule.test.mjs` imports this
+// module for its checks, and an unguarded `main()` launched a browser and printed
+// the usage text during that import — which made the whole test *file* fail rather
+// than any single assertion in it.
+if (isEntryPoint(import.meta.url)) {
+ try {
+ process.exitCode = await main();
+ } catch (error) {
+ console.error(`Error: ${error && error.message ? error.message : error}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/src/cli/args.mjs b/src/cli/args.mjs
new file mode 100644
index 00000000..30a4da19
--- /dev/null
+++ b/src/cli/args.mjs
@@ -0,0 +1,103 @@
+/**
+ * Minimal argument parser for the Distilly CLI (zero dependencies).
+ *
+ * Mirrors the subset of Python's argparse behaviour the ported tools relied on:
+ * `--flag value`, `--flag=value`, boolean switches, repeated options and
+ * positionals. Unknown options are a hard error — the CLI never guesses.
+ */
+
+/** Raised for user-facing argument problems (exit code 1). */
+export class ArgError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "ArgError";
+ }
+}
+
+/**
+ * @typedef {object} OptionSpec
+ * @property {'boolean'|'string'} type
+ * @property {string} [alias] short flag without dashes, e.g. `o`
+ * @property {string} [help]
+ * @property {boolean} [multiple] collect repeats into an array
+ * @property {string} [value] metavar shown in help
+ */
+
+function optionNames(longName, spec) {
+ const names = [`--${longName}`];
+ if (spec.alias) names.push(`-${spec.alias}`);
+ return names;
+}
+
+/**
+ * @param {string[]} argv
+ * @param {Record} spec
+ * @returns {{flags: Record, positionals: string[]}}
+ */
+export function parseArgs(argv, spec = {}) {
+ const byName = new Map();
+ for (const [longName, option] of Object.entries(spec)) {
+ for (const name of optionNames(longName, option)) byName.set(name, longName);
+ }
+
+ const flags = {};
+ for (const [longName, option] of Object.entries(spec)) {
+ if (option.multiple) flags[longName] = [];
+ else if (option.type === "boolean") flags[longName] = false;
+ else flags[longName] = undefined;
+ }
+
+ const positionals = [];
+ let index = 0;
+ while (index < argv.length) {
+ const arg = argv[index];
+ if (arg === "--") {
+ positionals.push(...argv.slice(index + 1));
+ break;
+ }
+ if (arg.startsWith("-") && arg !== "-") {
+ const equals = arg.indexOf("=");
+ const name = equals === -1 ? arg : arg.slice(0, equals);
+ const inlineValue = equals === -1 ? undefined : arg.slice(equals + 1);
+ const longName = byName.get(name);
+ if (!longName) throw new ArgError(`unrecognized argument: ${name}`);
+ const option = spec[longName];
+ if (option.type === "boolean") {
+ if (inlineValue !== undefined) {
+ throw new ArgError(`${name} does not take a value`);
+ }
+ flags[longName] = true;
+ } else {
+ const value = inlineValue !== undefined ? inlineValue : argv[index + 1];
+ if (value === undefined || (inlineValue === undefined && value.startsWith("-") && value !== "-")) {
+ throw new ArgError(`${name} requires a value`);
+ }
+ if (inlineValue === undefined) index += 1;
+ if (option.multiple) flags[longName].push(value);
+ else flags[longName] = value;
+ }
+ index += 1;
+ continue;
+ }
+ positionals.push(arg);
+ index += 1;
+ }
+
+ return { flags, positionals };
+}
+
+/** True when the argument list asks for help. */
+export function wantsHelp(argv) {
+ return argv.includes("--help") || argv.includes("-h");
+}
+
+/** Render ` ` fragments for a usage line. */
+export function usageFragment(spec = {}) {
+ const parts = [];
+ for (const [longName, option] of Object.entries(spec)) {
+ if (option.hidden) continue;
+ const bare = option.alias ? `-${option.alias}, --${longName}` : `--${longName}`;
+ parts.push(option.type === "boolean" ? `[${bare}]` : `[${bare} <${option.value ?? "value"}>]`);
+ }
+ return parts.join(" ");
+}
diff --git a/src/cli/entry.mjs b/src/cli/entry.mjs
new file mode 100644
index 00000000..24dff7bd
--- /dev/null
+++ b/src/cli/entry.mjs
@@ -0,0 +1,33 @@
+/**
+ * "Am I the process entry point?" — the guard every executable here needs.
+ *
+ * `bin/distilly.mjs` and the two runnable scripts under `scripts/` must dispatch
+ * only when they were invoked directly, because tests import their internals.
+ * The obvious spelling is wrong in a way that fails *silently*:
+ *
+ * resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+ *
+ * `import.meta.url` is always the realpath, while `argv[1]` is whatever the
+ * caller wrote. Under a symlink the two differ, the guard concludes "I was
+ * imported", and the program exits 0 having done nothing. That is not an edge
+ * case: `/tmp` is a symlink to `/private/tmp` on macOS, and — more importantly —
+ * an npm `bin` shim in `node_modules/.bin/` is a symlink, so a published
+ * `distilly` would have silently ignored every command.
+ *
+ * Resolving both sides is the fix. A path that cannot be resolved (an eval, a
+ * repl) is not an entry point, which is also the honest answer.
+ */
+
+import { realpathSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+/** True when `moduleUrl` names the file the process was started with. */
+export function isEntryPoint(moduleUrl) {
+ const invoked = process.argv[1];
+ if (invoked === undefined || invoked === "") return false;
+ try {
+ return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl));
+ } catch {
+ return false;
+ }
+}
diff --git a/src/cli/paths.mjs b/src/cli/paths.mjs
new file mode 100644
index 00000000..b51a7a0f
--- /dev/null
+++ b/src/cli/paths.mjs
@@ -0,0 +1,41 @@
+/**
+ * Where a Skill family lives on disk.
+ *
+ * Two spellings of `--base-dir` grew apart: `harvest` / `parse-*` treat it as the
+ * directory that *contains* `skills/`, while `skill create` treats it as the
+ * storage root itself (`skills/colleague`). Both are reasonable, and neither is
+ * written down, so the same value silently means different places — a trap for
+ * users and for the coding agents that script this CLI.
+ *
+ * `resolveSkillsRoot` accepts both: the canonical ` /skills/` wins
+ * whenever it exists (or `skills/` does), the storage-root reading is kept
+ * working for compatibility, and the caller gets a warning to surface when the
+ * legacy reading was used.
+ */
+
+import { existsSync } from "node:fs";
+import { basename, join, resolve } from "node:path";
+
+/**
+ * @param {{baseDir: string, family: string}} input
+ * @returns {{root: string, mode: "skills-root"|"storage-root", warning: string|null}}
+ */
+export function resolveSkillsRoot({ baseDir, family }) {
+ const base = resolve(baseDir);
+ const canonical = join(base, "skills", family);
+ if (existsSync(canonical)) return { root: canonical, mode: "skills-root", bare: false, warning: null };
+ if (basename(base) === family) return { root: base, mode: "storage-root", bare: false, warning: null };
+ if (existsSync(join(base, "skills"))) return { root: canonical, mode: "skills-root", bare: false, warning: null };
+ // The bare spelling — pointing at the container of the family directories
+ // rather than at a directory that holds `skills/`. It is read as-is (nothing is
+ // invented underneath it) and flagged, because every caller that accepts this
+ // spelling silently inspects one level too high and finds nothing.
+ return {
+ root: base,
+ mode: "storage-root",
+ bare: true,
+ warning:
+ `--base-dir ${baseDir} contains no skills/ directory, so it was read as the storage root itself (${base}). ` +
+ `The canonical spelling is --base-dir , i.e. ${join(base, "..", "..")} for this layout.`,
+ };
+}
diff --git a/src/cli/receipt.mjs b/src/cli/receipt.mjs
new file mode 100644
index 00000000..aea2d7a6
--- /dev/null
+++ b/src/cli/receipt.mjs
@@ -0,0 +1,118 @@
+/**
+ * CLI receipts, output routing and file fingerprints.
+ *
+ * The receipt shape is frozen by `docs/v2/CONTRACT.md` §3:
+ * `{command, person, ok, inputs, outputs, anchors, warnings, unavailable}`.
+ * Every entry in `inputs`/`outputs` carries `{path, sha256, bytes}`.
+ *
+ * `--json` writes the receipt to stdout as the only stdout content (human text
+ * moves to stderr) so `JSON.parse(stdout)` always succeeds.
+ */
+
+import { createHash } from "node:crypto";
+import { readFileSync, statSync } from "node:fs";
+import { relative, resolve, sep } from "node:path";
+
+/** User-facing failure with an explicit remedy. */
+export class CliError extends Error {
+ constructor(message, { code = "error", remedy = "", exitCode = 1 } = {}) {
+ super(message);
+ this.name = "CliError";
+ this.code = code;
+ this.remedy = remedy;
+ this.exitCode = exitCode;
+ }
+}
+
+export function sha256Buffer(buffer) {
+ return createHash("sha256").update(buffer).digest("hex");
+}
+
+export function sha256Text(text) {
+ return sha256Buffer(Buffer.from(text, "utf8"));
+}
+
+/** `{path, sha256, bytes}` for a file, or null when it is missing. */
+export function describeFile(filePath, { cwd = process.cwd() } = {}) {
+ let buffer;
+ try {
+ buffer = readFileSync(filePath);
+ } catch {
+ return null;
+ }
+ return {
+ path: displayPath(filePath, cwd),
+ sha256: sha256Buffer(buffer),
+ bytes: buffer.length,
+ };
+}
+
+/** `{path, bytes}` without hashing (used for directories and removals). */
+export function describePath(filePath, { cwd = process.cwd() } = {}) {
+ return { path: displayPath(filePath, cwd), bytes: directoryBytes(filePath) };
+}
+
+export function directoryBytes(dirPath) {
+ try {
+ return statSync(dirPath).size;
+ } catch {
+ return 0;
+ }
+}
+
+/** Paths inside the working directory are reported relatively, like the old CLI. */
+export function displayPath(filePath, cwd = process.cwd()) {
+ const absolute = resolve(filePath);
+ const rel = relative(resolve(cwd), absolute);
+ if (rel === "") return ".";
+ if (!rel.startsWith("..") && !rel.startsWith(`${sep}..`)) return rel;
+ return absolute;
+}
+
+/** Build the contract receipt object (field order is contractual). */
+export function createReceipt(command, options = {}) {
+ const receipt = {
+ command,
+ person: options.person ?? null,
+ ok: options.ok ?? true,
+ inputs: options.inputs ?? [],
+ outputs: options.outputs ?? [],
+ anchors: options.anchors ?? { total: 0, cited: 0 },
+ warnings: options.warnings ?? [],
+ unavailable: options.unavailable ?? [],
+ };
+ if (options.error) receipt.error = options.error;
+ return receipt;
+}
+
+/**
+ * Route human text and machine receipts.
+ * In `--json` mode stdout carries exactly one JSON object; prose goes to stderr.
+ */
+export function createReporter(json, { stdout = process.stdout, stderr = process.stderr } = {}) {
+ const lines = [];
+ return {
+ json,
+ line(text) {
+ lines.push(text);
+ if (json) stderr.write(`${text}\n`);
+ else stdout.write(`${text}\n`);
+ },
+ warn(text) {
+ stderr.write(`${text}\n`);
+ },
+ /**
+ * A diagnostic that always goes to stderr, in both modes. Command output is
+ * `line()`; this is for "why the command failed", which must never pollute
+ * the machine channel — and must still be visible when `--json` is set.
+ */
+ error(text) {
+ stderr.write(`${text}\n`);
+ },
+ /** Write the receipt last so `JSON.parse(stdout)` sees a single object. */
+ finish(receipt) {
+ if (!json) return;
+ stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
+ },
+ };
+}
diff --git a/src/collect/dingtalk.mjs b/src/collect/dingtalk.mjs
new file mode 100644
index 00000000..34ecb24c
--- /dev/null
+++ b/src/collect/dingtalk.mjs
@@ -0,0 +1,832 @@
+/**
+ * dingtalk.mjs — credentialed collection from DingTalk.
+ *
+ * Legacy ported: `tools/dingtalk_auto_collector.py` (790 lines). What that file
+ * actually proves about DingTalk's API surface matters here, so this module
+ * splits the channel the same way the legacy did:
+ *
+ * - **api mode** — read-only endpoints this repository has evidence for: the
+ * app credential exchange (`POST /v1.0/oauth2/accessToken`) and the contact
+ * directory card lookup (`POST /v1.0/contact/users/search`,
+ * `GET /v1.0/contact/users/{userId}`). Both are used by the legacy collector
+ * (`tools/dingtalk_auto_collector.py:106`, `:151`, `:202`).
+ * - **message history** — the legacy collector has **no API path** for it; it
+ * drives a browser instead (`tools/dingtalk_auto_collector.py:518`,
+ * "消息类(可选,仅用于发消息,历史消息需浏览器方案)"). Rather than invent an
+ * endpoint, api mode says so, loudly, and browser mode requires a consent
+ * token. The host performs the computer use and hands the bytes back through
+ * `--capture