s20 Comprehensive Agent └─ 全机制回单循环"]:::stage6
S4 ==> S5 ==> S6
end
- %% 将两个模块连接起来,形成 Z 字形阅读流
- Phase1 ===> Phase2
+ %% 第三层:编排与目标闭环
+ subgraph Phase3 ["🎯 第七阶段:编排与目标闭环"]
+ direction LR
+ S7["第七阶段:编排并完成 ━━━━━━━━━━━━━ s21 Workflow Runtime └─ 脚本拥有固定编排
s22 Goal Loop └─ 可信证据决定何时停止"]:::stage1
+ S6 ==> S7
+ end
+
+ %% 将三个模块连接起来,形成 Z 字形阅读流
+ Phase1 ===> Phase2 ===> Phase3
%% 应用背景样式
- class Phase1,Phase2 groupBox
+ class Phase1,Phase2,Phase3 groupBox
```
## 全部章节
@@ -367,42 +378,46 @@ flowchart TD
| [s07](./s07_skill_loading/) | Skill Loading | `SkillManifest` / 按需注入 |
| [s08](./s08_context_compact/) | Context Compact | snip / micro / budget / auto 四层压缩 |
| [s09](./s09_memory/) | Memory | selection / extraction / consolidation |
-| [s10](./s10_system_prompt/) | System Prompt | 运行时组装 / 分段拼接 |
+| [s10](./s10_system_prompt/) | Context Assembly | 运行时状态 / 稳定分段 / 模型输入 |
| [s11](./s11_error_recovery/) | Error Recovery | token 升级 / fallback 模型 / 重试策略 |
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / 磁盘持久化 |
| [s13](./s13_background_tasks/) | Background Tasks | 线程执行 / 通知队列 |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | 持久化调度 / 会话级触发 |
-| [s15](./s15_agent_teams/) | Agent Teams | `MessageBus` / 收件箱 / 权限冒泡 |
-| [s16](./s16_team_protocols/) | Team Protocols | 关机握手 / 计划审批 |
+| [s15](./s15_agent_teams/) | Agent Teams:运行时实验 | `MessageBus` / 收件箱 / 权限冒泡 |
+| [s16](./s16_team_protocols/) | Agent Teams:协议实验 | 类型消息 / 关机握手 / 计划审批 |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | 空闲循环 / 自动认领 |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 |
| [s19](./s19_mcp_plugin/) | MCP Plugin | 多传输 / 通道路由 / 工具池组装 |
| [s20](./s20_comprehensive/) | Comprehensive Agent | 全部机制归到一个循环 |
+| [s21](./s21_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 |
+| [s22](./s22_goal_loop/) | Goal Loop | 目标闸门 / 可信证据 / 自动续轮 |
## 项目结构
```
learn-claude-code/
s01_agent_loop/ # 每章一个文件夹
- README.md # 中文源文档(完整叙事)
- README.en.md # 英文译本
+ README.md # 默认英文文档(完整叙事)
+ README.zh.md # 中文译本
README.ja.md # 日文译本
code.py # 独立可运行代码
images/ # SVG 流程图
s02_tool_use/
...
s19_mcp_plugin/
- s20_comprehensive/ # 终点章
+ s20_comprehensive/
+ s21_workflow_runtime/
+ s22_goal_loop/ # 终点章
agents/ # 旧 12 章可运行副本 + s_full.py
skills/ # s07 使用的 skill 文件
docs/ # 旧 12 章文档,过渡期保留
- web/ # 当前仍基于 docs/ 旧版内容生成
+ web/ # 从根目录课程生成
tests/
```
## 学完之后 -- 从理解到落地
-20 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
+22 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
diff --git a/README.md b/README.md
index 5acc0f20..68ac8f1e 100644
--- a/README.md
+++ b/README.md
@@ -167,16 +167,16 @@ The loop is constant. Tools, knowledge, and permissions change. Agent = Model (L
This repository currently contains two tutorial tracks:
-- **Current track: root-level `s01-s20`**
- The root-level `s01_*` ... `s20_*` folders are the new canonical version. Each chapter contains a full narrative README, translations, runnable `code.py`, and diagrams where needed.
-- **Legacy transition track: `docs/`, `agents/`, and the current `web/` app**
- These still preserve the older 12-lesson version. They are kept temporarily for existing readers, old links, and the web platform while the new 20-lesson track settles.
+- **Current track: root-level `s01-s22`**
+ The root-level `s01_*` ... `s22_*` folders are the canonical version. Each chapter contains an English default README, Chinese/Japanese translations, runnable `code.py`, and diagrams where needed.
+- **Legacy transition track: `docs/` and `agents/`**
+ These preserve the older 12-lesson version for existing readers and old links during migration.
-If you are starting now, read the root-level `s01_agent_loop/` through `s20_comprehensive/` chapters. If you are following an older link or using the current web app, you are likely reading the legacy 12-lesson track. The legacy and current chapter numbers do not always match, so avoid mixing chapter numbers across tracks.
+If you are starting now, read the root-level `s01_agent_loop/` through `s22_goal_loop/` chapters. The legacy and current chapter numbers do not always match, so avoid mixing chapter numbers across tracks.
### Legacy-to-Current Mapping
-| Legacy 12-lesson track | Current 20-lesson track | Topic |
+| Legacy 12-lesson track | Current 22-lesson track | Topic |
|---|---|---|
| old s01 | new s01 | Agent Loop |
| old s02 | new s02 | Tool Use |
@@ -190,7 +190,7 @@ If you are starting now, read the root-level `s01_agent_loop/` through `s20_comp
| old s10 | new s16 | Team Protocols |
| old s11 | new s17 | Autonomous Agents |
| old s12 | new s18 | Worktree Isolation |
-| new only | s03, s04, s09, s10, s11, s14, s19, s20 | Permission, Hooks, Memory, System Prompt, Error Recovery, Cron, MCP, Comprehensive Agent |
+| new only | s03, s04, s09, s10, s11, s14, s19, s20, s21, s22 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Comprehensive Agent, Workflow Runtime, Goal Loop |
---
@@ -208,7 +208,7 @@ The JSONL mailbox protocol in this repository is a teaching implementation, not
---
-## 20 Progressive Lessons
+## 22 Progressive Lessons
**Each lesson adds one harness mechanism. Each mechanism has a motto.**
@@ -240,9 +240,9 @@ The JSONL mailbox protocol in this repository is a teaching implementation, not
>
> **s14** *"Fire on schedule, no human kick needed"* — trigger tasks automatically by time
>
-> **s15** *"Too big for one agent -- delegate to teammates"* — persistent teammates + async mailboxes
+> **s15** *"Too big for one agent -- delegate to teammates"* — Agent Teams runtime lab: persistent teammates + async mailboxes
>
-> **s16** *"Teammates need shared communication rules"* — use a fixed request-reply format for coordination
+> **s16** *"Teammates need shared communication rules"* — Agent Teams protocol lab: typed request-reply coordination
>
> **s17** *"Teammates check the board, claim work themselves"* — no leader assigning one by one; self-organizing
>
@@ -251,12 +251,16 @@ The JSONL mailbox protocol in this repository is a teaching implementation, not
> **s19** *"Not enough capability? Plug in more via MCP"* — connect external tools into the same tool pool
>
> **s20** *"Many mechanisms, one loop"* — all previous mechanisms return to one complete harness
+>
+> **s21** *"When the orchestration shape is fixed, put it in code"* — deterministic workflows with resumable journals
+>
+> **s22** *"A goal decides when the loop may stop"* — continue until trusted evidence satisfies the goal
---
## Learning Path
-Main line: act → handle complex work → remember and recover → run long tasks → collaborate → extend and assemble.
+Main line: act → handle complex work → remember and recover → run long tasks → collaborate → extend and assemble → orchestrate and close goals.
```mermaid
flowchart TD
@@ -278,7 +282,7 @@ flowchart TD
S2["2. Handle complex work ━━━━━━━━━━━━━ s05 TodoWrite └─ plan first, then execute
s06 Subagent └─ side work, result back
s08 Context Compact └─ make room in long context"]:::stage2
- S3["3. Remember and recover ━━━━━━━━━━━━━ s09 Memory └─ remember what matters
s10 System Prompt └─ assemble at runtime
s11 Error Recovery └─ retry or change path"]:::stage3
+ S3["3. Remember and recover ━━━━━━━━━━━━━ s09 Memory └─ persist and recall across sessions
s10 Context Assembly └─ build model input from runtime state
s11 Error Recovery └─ retry or change path"]:::stage3
S1 ==> S2 ==> S3
end
@@ -288,17 +292,24 @@ flowchart TD
direction LR
S4["4. Run long tasks ━━━━━━━━━━━━━ s12 Task System └─ persist tasks and deps
s13 Background Tasks └─ send slow work background
s14 Cron Scheduler └─ trigger by time"]:::stage4
- S5["5. Coordinate many Agents ━━━━━━━━━━━━━ s15 Agent Teams └─ teammates + mailboxes
s16 Team Protocols └─ fixed request-reply format
s17 Autonomous Agents └─ claim work from the board
s18 Worktree Isolation └─ separate directories"]:::stage5
+ S5["5. Coordinate many Agents ━━━━━━━━━━━━━ Agent Teams module ├─ s15 Runtime Lab: teammates + mailboxes └─ s16 Protocol Lab: typed request-reply
s17 Autonomous Agents └─ claim work from the board
s18 Worktree Isolation └─ separate directories"]:::stage5
S6["6. Extend and assemble ━━━━━━━━━━━━━ s07 Skill Loading └─ expand skills on demand
s19 MCP Plugin └─ external tools, one pool
s20 Comprehensive Agent └─ all mechanisms, one loop"]:::stage6
S4 ==> S5 ==> S6
end
- %% Connect the two layers
- Phase1 ===> Phase2
+ %% Layer 3: orchestration and goal closure
+ subgraph Phase3 ["🎯 Stage 7: Orchestration and goal closure"]
+ direction LR
+ S7["7. Orchestrate and finish ━━━━━━━━━━━━━ s21 Workflow Runtime └─ scripts own fixed orchestration
s22 Goal Loop └─ trusted evidence decides when to stop"]:::stage1
+ S6 ==> S7
+ end
- class Phase1,Phase2 groupBox
+ %% Connect the three layers
+ Phase1 ===> Phase2 ===> Phase3
+
+ class Phase1,Phase2,Phase3 groupBox
```
---
@@ -316,17 +327,19 @@ flowchart TD
| [s07](./s07_skill_loading/) | Skill Loading | `SkillManifest` / on-demand injection |
| [s08](./s08_context_compact/) | Context Compact | snipCompact / microCompact / toolResultBudget / autoCompact |
| [s09](./s09_memory/) | Memory System | selection / extraction / consolidation |
-| [s10](./s10_system_prompt/) | System Prompt | runtime assembly / section concatenation |
+| [s10](./s10_system_prompt/) | Context Assembly | runtime state / stable sections / model input |
| [s11](./s11_error_recovery/) | Error Recovery | token escalation / fallback model / retry strategies |
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / disk persistence |
| [s13](./s13_background_tasks/) | Background Tasks | threaded execution / notification queue |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | durable scheduling / session-scoped triggers |
-| [s15](./s15_agent_teams/) | Agent Teams | `MessageBus` / inbox / permission bubbling |
-| [s16](./s16_team_protocols/) | Team Protocols | shutdown handshake / plan approval |
+| [s15](./s15_agent_teams/) | Agent Teams: Runtime Lab | `MessageBus` / inbox / permission bubbling |
+| [s16](./s16_team_protocols/) | Agent Teams: Protocol Lab | typed messages / shutdown handshake / plan approval |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | idle cycle / auto-claim / self-organization |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / task-directory binding |
| [s19](./s19_mcp_plugin/) | MCP Plugin | multi-transport / channel routing / tool pool assembly |
| [s20](./s20_comprehensive/) | Comprehensive Agent | all mechanisms around one loop |
+| [s21](./s21_workflow_runtime/) | Workflow Runtime | script orchestration / background execution / journal resume |
+| [s22](./s22_goal_loop/) | Goal Loop | goal gate / trusted evidence / automatic continuation |
---
@@ -336,8 +349,8 @@ Each chapter is a folder. Open one and you will find:
```
s08_context_compact/
- README.md # full narrative with inline code
- README.en.md # English translation
+ README.md # English, the default chapter README
+ README.zh.md # Chinese translation
README.ja.md # Japanese translation
code.py # standalone runnable implementation
images/ # SVG diagrams (where needed)
@@ -345,13 +358,13 @@ s08_context_compact/
Read the `README.md` for the core idea and work through the code. Complex chapters have `` folds for deep dives -- open them when you want to go deeper. Simple chapters have 0-1 diagrams, complex chapters have more.
-Read from s01 through s20 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
+Read from s01 through s22 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
---
## Quick Start
-### Current 20-Lesson Track
+### Current 22-Lesson Track
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@@ -361,7 +374,7 @@ cp .env.example .env # configure ANTHROPIC_API_KEY
python s01_agent_loop/code.py # Start here -- one loop + bash
python s08_context_compact/code.py # Context compaction (complex)
-python s20_comprehensive/code.py # Endpoint: all mechanisms in one loop
+python s22_goal_loop/code.py # Endpoint: close the loop with a durable goal
```
### Legacy 12-Lesson Track
@@ -374,7 +387,7 @@ python agents/s_full.py
### Web Platform
-The current web app still renders the legacy `docs/` s01-s12 track. Use the root-level folders for the new s01-s20 track.
+The web app extracts the root-level course. Lessons s21 and s22 include reading, source, simulator, and architecture views; only their dedicated hero visualizations remain intentionally minimal.
```sh
cd web && npm install && npm run dev # http://localhost:3000
@@ -387,19 +400,21 @@ cd web && npm install && npm run dev # http://localhost:3000
```
learn-claude-code/
s01_agent_loop/ # one folder per chapter
- README.md # Chinese source (complete narrative)
- README.en.md # English translation
+ README.md # English default (complete narrative)
+ README.zh.md # Chinese translation
README.ja.md # Japanese translation
code.py # standalone runnable code
images/ # SVG diagrams
s02_tool_use/
...
s19_mcp_plugin/
- s20_comprehensive/ # endpoint chapter
+ s20_comprehensive/
+ s21_workflow_runtime/
+ s22_goal_loop/ # endpoint chapter
agents/ # legacy 12 runnable copies + s_full.py
skills/ # skill files used by s07
docs/ # legacy 12-lesson docs, kept during transition
- web/ # currently renders the legacy docs/ track
+ web/ # generated from the root-level course
tests/
```
@@ -407,7 +422,7 @@ learn-claude-code/
## What's Next
-After 20 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
+After 22 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
### Kode Agent CLI -- Open-Source Coding Agent CLI
diff --git a/s01_agent_loop/README.en.md b/s01_agent_loop/README.en.md
deleted file mode 100644
index 92f76179..00000000
--- a/s01_agent_loop/README.en.md
+++ /dev/null
@@ -1,207 +0,0 @@
-# s01: The Agent Loop — One Loop Is All You Need
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
-> *"One loop & Bash is all you need"* — One tool + one loop = one Agent.
->
-> **Harness Layer**: The Loop — the first bridge between the model and the real world.
-
----
-
-## The Problem
-
-You ask the model: "List the files in my directory and run XXX.py."
-
-The model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result.
-
-You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.
-
-Every round-trip, you're the middle layer. Automating that is what this chapter is about.
-
----
-
-## The Solution
-
-
-
-A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:
-
-| Signal | Meaning | Loop Action |
-|--------|---------|-------------|
-| `stop_reason == "tool_use"` | Model raises hand: "I need a tool" | Execute → feed result back → continue |
-| `stop_reason != "tool_use"` | Model says: "I'm done" | Exit loop |
-
----
-
-## How It Works
-
-Let's translate this process into code. Step by step:
-
-**Step 1**: Start with the user's question as the first message.
-
-```python
-messages = [{"role": "user", "content": query}]
-```
-
-**Step 2**: Send the messages and tool definitions to the LLM.
-
-```python
-response = client.messages.create(
- model=MODEL, system=SYSTEM, messages=messages,
- tools=TOOLS, max_tokens=8000,
-)
-```
-
-**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.
-
-```python
-messages.append({"role": "assistant", "content": response.content})
-if response.stop_reason != "tool_use":
- return
-```
-
-**Step 4**: Execute the tool the model requested and collect the results.
-
-```python
-results = []
-for block in response.content:
- if block.type == "tool_use":
- output = run_bash(block.input["command"])
- results.append({
- "type": "tool_result",
- "tool_use_id": block.id,
- "content": output,
- })
-```
-
-**Step 5**: Append the tool results as a new message and go back to Step 2.
-
-```python
-messages.append({"role": "user", "content": results})
-```
-
-Assembled into a complete function:
-
-```python
-def agent_loop(messages):
- while True:
- response = client.messages.create(
- model=MODEL, system=SYSTEM, messages=messages,
- tools=TOOLS, max_tokens=8000,
- )
- messages.append({"role": "assistant", "content": response.content})
-
- if response.stop_reason != "tool_use":
- return
-
- results = []
- for block in response.content:
- if block.type == "tool_use":
- output = run_bash(block.input["command"])
- results.append({
- "type": "tool_result",
- "tool_use_id": block.id,
- "content": output,
- })
- messages.append({"role": "user", "content": results})
-```
-
-Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.
-
----
-
-## Try It
-
-> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.
-
-**Setup** (first run):
-
-```sh
-pip install -r requirements.txt
-cp .env.example .env
-# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID
-```
-
-**Run**:
-
-```sh
-python s01_agent_loop/code.py
-```
-
-Try these prompts:
-
-1. `Create a file called hello.py that prints "Hello, World!"`
-2. `List all Python files in this directory`
-3. `What is the current git branch?`
-
-What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?
-
----
-
-## What's Next
-
-Right now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.
-
-→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
-
-
-Dive into CC Source Code
-
-> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.
-
-**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.
-
-
-1. Loop Structure Differences
-
-The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.
-
-```typescript
-// query.ts:554-558
-// stop_reason === 'tool_use' is unreliable.
-// Set during streaming whenever a tool_use block arrives.
-let needsFollowUp = false
-```
-
-
-
-
-2. State Object — 10 Fields (Teaching Version Only Uses messages)
-
-| # | Field | Purpose | Chapter |
-|---|-------|---------|---------|
-| 1 | `messages` | Message array for the current iteration | s01 |
-| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |
-| 3 | `autoCompactTracking` | Compaction state tracking | s08 |
-| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |
-| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |
-| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |
-| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |
-| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |
-| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |
-| 10 | `transition` | Last continue reason | s11 |
-
-> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
-
-
-
-
-3. Multiple Exit and Continue Paths
-
-The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
-
-
-
-
-4. Streaming Tool Execution and QueryEngine
-
-CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
-
-
-
-**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.
-
-
-
-
diff --git a/s01_agent_loop/README.ja.md b/s01_agent_loop/README.ja.md
index 636af477..22b68c9f 100644
--- a/s01_agent_loop/README.ja.md
+++ b/s01_agent_loop/README.ja.md
@@ -1,8 +1,8 @@
# s01: Agent Loop — ループ一つで十分
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
+`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
> *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。
>
> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。
diff --git a/s01_agent_loop/README.md b/s01_agent_loop/README.md
index 3b109715..c168bd6f 100644
--- a/s01_agent_loop/README.md
+++ b/s01_agent_loop/README.md
@@ -1,50 +1,50 @@
-# s01: Agent Loop — 一个循环就够了
+# s01: The Agent Loop — One Loop Is All You Need
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
-> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
+`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
+> *"One loop & Bash is all you need"* — One tool + one loop = one Agent.
>
-> **Harness 层**: 循环 — 模型与真实世界的第一道连接。
+> **Harness Layer**: The Loop — the first bridge between the model and the real world.
---
-## 问题
+## The Problem
-你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。
+You ask the model: "List the files in my directory and run XXX.py."
-模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。
+The model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result.
-你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
+You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.
-每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。
+Every round-trip, you're the middle layer. Automating that is what this chapter is about.
---
-## 解决方案
+## The Solution
-
+
-一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:
+A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:
-| 信号 | 含义 | 循环动作 |
-|------|------|---------|
-| `stop_reason == "tool_use"` | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
-| `stop_reason != "tool_use"` | 模型说"我做完了" | 退出循环 |
+| Signal | Meaning | Loop Action |
+|--------|---------|-------------|
+| `stop_reason == "tool_use"` | Model raises hand: "I need a tool" | Execute → feed result back → continue |
+| `stop_reason != "tool_use"` | Model says: "I'm done" | Exit loop |
---
-## 工作原理
+## How It Works
-将这个过程翻译成代码。分步来看:
+Let's translate this process into code. Step by step:
-**第 1 步**:把用户的问题作为第一条消息。
+**Step 1**: Start with the user's question as the first message.
```python
messages = [{"role": "user", "content": query}]
```
-**第 2 步**:将消息和工具定义一起发给 LLM。
+**Step 2**: Send the messages and tool definitions to the LLM.
```python
response = client.messages.create(
@@ -53,7 +53,7 @@ response = client.messages.create(
)
```
-**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。
+**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.
```python
messages.append({"role": "assistant", "content": response.content})
@@ -61,7 +61,7 @@ if response.stop_reason != "tool_use":
return
```
-**第 4 步**:执行模型要求的工具,收集结果。
+**Step 4**: Execute the tool the model requested and collect the results.
```python
results = []
@@ -75,13 +75,13 @@ for block in response.content:
})
```
-**第 5 步**:把工具结果作为新消息追加,回到第 2 步。
+**Step 5**: Append the tool results as a new message and go back to Step 2.
```python
messages.append({"role": "user", "content": results})
```
-组装为一个完整函数:
+Assembled into a complete function:
```python
def agent_loop(messages):
@@ -107,55 +107,55 @@ def agent_loop(messages):
messages.append({"role": "user", "content": results})
```
-不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。
+Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.
---
-## 试一下
+## Try It
-> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。
+> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.
-**准备**(首次运行):
+**Setup** (first run):
```sh
pip install -r requirements.txt
cp .env.example .env
-# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID
+# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID
```
-**运行**:
+**Run**:
```sh
python s01_agent_loop/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
-观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
+What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?
---
-## 接下来
+## What's Next
-现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。
+Right now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.
-s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
+→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
-深入 CC 源码
+Dive into CC Source Code
-> 以下内容基于 CC 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。
+> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.
-**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。
+**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.
-一、循环结构差异
+1. Loop Structure Differences
-教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。
+The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.
```typescript
// query.ts:554-558
@@ -167,41 +167,41 @@ let needsFollowUp = false
-二、State 对象 10 字段(教学版只用 messages)
+2. State Object — 10 Fields (Teaching Version Only Uses messages)
-| # | 字段 | 用途 | 对应章节 |
-|---|------|------|---------|
-| 1 | `messages` | 当前迭代的消息数组 | s01 |
-| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |
-| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |
-| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 |
-| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |
-| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |
-| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |
-| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |
-| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |
-| 10 | `transition` | 上一次继续原因 | s11 |
+| # | Field | Purpose | Chapter |
+|---|-------|---------|---------|
+| 1 | `messages` | Message array for the current iteration | s01 |
+| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |
+| 3 | `autoCompactTracking` | Compaction state tracking | s08 |
+| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |
+| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |
+| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |
+| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |
+| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |
+| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |
+| 10 | `transition` | Last continue reason | s11 |
-> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
+> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
-三、多条退出和继续路径
+3. Multiple Exit and Continue Paths
-教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
+The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
-四、流式工具执行和 QueryEngine
+4. Streaming Tool Execution and QueryEngine
-CC 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。
+CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
-**一句话**:1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
+**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.
-
+
diff --git a/s01_agent_loop/README.zh.md b/s01_agent_loop/README.zh.md
new file mode 100644
index 00000000..06ec0589
--- /dev/null
+++ b/s01_agent_loop/README.zh.md
@@ -0,0 +1,207 @@
+# s01: Agent Loop — 一个循环就够了
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
+> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
+>
+> **Harness 层**: 循环 — 模型与真实世界的第一道连接。
+
+---
+
+## 问题
+
+你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。
+
+模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。
+
+你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
+
+每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。
+
+---
+
+## 解决方案
+
+
+
+一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:
+
+| 信号 | 含义 | 循环动作 |
+|------|------|---------|
+| `stop_reason == "tool_use"` | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
+| `stop_reason != "tool_use"` | 模型说"我做完了" | 退出循环 |
+
+---
+
+## 工作原理
+
+将这个过程翻译成代码。分步来看:
+
+**第 1 步**:把用户的问题作为第一条消息。
+
+```python
+messages = [{"role": "user", "content": query}]
+```
+
+**第 2 步**:将消息和工具定义一起发给 LLM。
+
+```python
+response = client.messages.create(
+ model=MODEL, system=SYSTEM, messages=messages,
+ tools=TOOLS, max_tokens=8000,
+)
+```
+
+**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。
+
+```python
+messages.append({"role": "assistant", "content": response.content})
+if response.stop_reason != "tool_use":
+ return
+```
+
+**第 4 步**:执行模型要求的工具,收集结果。
+
+```python
+results = []
+for block in response.content:
+ if block.type == "tool_use":
+ output = run_bash(block.input["command"])
+ results.append({
+ "type": "tool_result",
+ "tool_use_id": block.id,
+ "content": output,
+ })
+```
+
+**第 5 步**:把工具结果作为新消息追加,回到第 2 步。
+
+```python
+messages.append({"role": "user", "content": results})
+```
+
+组装为一个完整函数:
+
+```python
+def agent_loop(messages):
+ while True:
+ response = client.messages.create(
+ model=MODEL, system=SYSTEM, messages=messages,
+ tools=TOOLS, max_tokens=8000,
+ )
+ messages.append({"role": "assistant", "content": response.content})
+
+ if response.stop_reason != "tool_use":
+ return
+
+ results = []
+ for block in response.content:
+ if block.type == "tool_use":
+ output = run_bash(block.input["command"])
+ results.append({
+ "type": "tool_result",
+ "tool_use_id": block.id,
+ "content": output,
+ })
+ messages.append({"role": "user", "content": results})
+```
+
+不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。
+
+---
+
+## 试一下
+
+> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。
+
+**准备**(首次运行):
+
+```sh
+pip install -r requirements.txt
+cp .env.example .env
+# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID
+```
+
+**运行**:
+
+```sh
+python s01_agent_loop/code.py
+```
+
+试试这些 prompt:
+
+1. `Create a file called hello.py that prints "Hello, World!"`
+2. `List all Python files in this directory`
+3. `What is the current git branch?`
+
+观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
+
+---
+
+## 接下来
+
+现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。
+
+s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
+
+
+深入 CC 源码
+
+> 以下内容基于 CC 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。
+
+**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。
+
+
+一、循环结构差异
+
+教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。
+
+```typescript
+// query.ts:554-558
+// stop_reason === 'tool_use' is unreliable.
+// Set during streaming whenever a tool_use block arrives.
+let needsFollowUp = false
+```
+
+
+
+
+二、State 对象 10 字段(教学版只用 messages)
+
+| # | 字段 | 用途 | 对应章节 |
+|---|------|------|---------|
+| 1 | `messages` | 当前迭代的消息数组 | s01 |
+| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |
+| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |
+| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 |
+| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |
+| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |
+| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |
+| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |
+| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |
+| 10 | `transition` | 上一次继续原因 | s11 |
+
+> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
+
+
+
+
+三、多条退出和继续路径
+
+教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
+
+
+
+
+四、流式工具执行和 QueryEngine
+
+CC 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。
+
+
+
+**一句话**:1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
+
+
+
+
diff --git a/s02_tool_use/README.en.md b/s02_tool_use/README.en.md
deleted file mode 100644
index b939810a..00000000
--- a/s02_tool_use/README.en.md
+++ /dev/null
@@ -1,222 +0,0 @@
-# s02: Tool Use — Add a Tool, Add Just One Line
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
-> *"Add a tool, add just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done.
->
-> **Harness Layer**: Tool Dispatch — Expanding the model's reach.
-
----
-
-## Only One Tool: Bash
-
-The s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo "..." > file.py`; to edit, `sed`.
-
-The model thinks "read this file" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.
-
----
-
-## Overview: Tool Dispatch
-
-
-
-The s01 loop is fully preserved (LLM call, stop_reason check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.
-
-Adding a tool to the Agent requires just two things:
-
-1. **Define the tool**: Add one entry to the `TOOLS` array
-2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict
-
----
-
-## From 1 Tool to 5 Tools
-
-s01 had only bash:
-
-```python
-TOOLS = [{"name": "bash", ...}]
-
-def run_bash(command): ...
-```
-
-s02 expands to 5 tools, each independently defined:
-
-```python
-TOOLS = [
- {"name": "bash", "description": "Run a shell command.", ...},
- {"name": "read_file", "description": "Read file contents.", ...},
- {"name": "write_file", "description": "Write content to file.", ...},
- {"name": "edit_file", "description": "Replace text in file once.", ...},
- {"name": "glob", "description": "Find files by pattern.", ...},
-]
-```
-
-Each tool has its own implementation function:
-
-```python
-def run_read(path, limit=None):
- lines = safe_path(path).read_text().splitlines()
- if limit:
- lines = lines[:limit]
- return "\n".join(lines)
-
-def run_write(path, content):
- safe_path(path).write_text(content)
- return f"Wrote {len(content)} bytes to {path}"
-
-def run_edit(path, old_text, new_text):
- text = safe_path(path).read_text()
- if old_text not in text:
- return "Error: text not found"
- safe_path(path).write_text(text.replace(old_text, new_text, 1))
- return f"Edited {path}"
-
-def run_glob(pattern):
- import glob as g
- return "\n".join(g.glob(pattern, root_dir=WORKDIR))
-```
-
----
-
-## Tool Dispatch
-
-```python
-TOOL_HANDLERS = {
- "bash": run_bash,
- "read_file": run_read,
- "write_file": run_write,
- "edit_file": run_edit,
- "glob": run_glob,
-}
-
-# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:
-for block in response.content:
- if block.type == "tool_use":
- handler = TOOL_HANDLERS[block.name] # lookup
- output = handler(**block.input) # call
- results.append(...)
-```
-
-Adding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same.
-
----
-
-## Multiple Tool Calls
-
-The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files".
-
-The teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).
-
----
-
-## Quick Reference
-
-| Concept | One-Liner |
-|---------|-----------|
-| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |
-| Tool Definition | JSON schema telling the model "what I can do" |
-| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |
-| Loop Unchanged | s01's `while True` loop — not a single line changed |
-
----
-
-## Changes from s01
-
-| Component | Before (s01) | After (s02) |
-|-----------|-------------|-------------|
-| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |
-| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |
-| Path safety | None | safe_path validation (file tools only) |
-| Loop | `while True` + `stop_reason` | Identical to s01 |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s02_tool_use/code.py
-```
-
-Try these prompts:
-
-1. `Read the file README.md and tell me what this project is about`
-2. `Create a file called test.py that prints "hello", then read it back`
-3. `Find all Python files in this directory`
-4. `Read both README.md and requirements.txt, then create a summary file`
-
-What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?
-
----
-
-## What's Next
-
-The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.
-
-→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?
-
-
-Dive into CC Source Code
-
-> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.
-
-### 1. Tool Definition Approach
-
-**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.
-**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.
-
-The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions".
-
-### 2. Concurrency Safety: isConcurrencySafe()
-
-
-
-The teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input:
-
-| | isReadOnly | isConcurrencySafe |
-|---|---|---|
-| FileRead | true | true |
-| Glob | true | true |
-| Bash `ls` | true | **true** ← key difference |
-| Bash `rm` | false | false |
-| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |
-
-CC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.
-
-### 3. Partition Algorithm
-
-CC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:
-
-```
-[read A, read B, glob *.py, bash "rm x", read C]
- → batch1(concurrent): [read A, read B, glob *.py]
- → batch2(serial): [bash "rm x"]
- → batch3(concurrent): [read C]
-```
-
-Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.
-
-### 4. Validation Pipeline
-
-Each tool call in CC goes through a strict 5-step validation (`toolExecution.ts`):
-
-1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check
-2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)
-3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution
-4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask
-5. **Execute tool.call()** (`1207-1222`)
-
-The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.
-
-### 5. Streaming Tool Execution
-
-CC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.
-
-### 6. Tool Result Persistence
-
-Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).
-
-
-
-
diff --git a/s02_tool_use/README.ja.md b/s02_tool_use/README.ja.md
index 23ff30aa..1711e11e 100644
--- a/s02_tool_use/README.ja.md
+++ b/s02_tool_use/README.ja.md
@@ -1,8 +1,8 @@
# s02: Tool Use — ツール一つ追加、一行追加だけ
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
+s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
> *"ツールを一つ追加、ハンドラを一つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。
>
> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。
diff --git a/s02_tool_use/README.md b/s02_tool_use/README.md
index 179df58c..3a668bfb 100644
--- a/s02_tool_use/README.md
+++ b/s02_tool_use/README.md
@@ -1,38 +1,38 @@
-# s02: Tool Use — 多加一个工具,只加一行
+# s02: Tool Use — Add a Tool, Add Just One Line
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
-> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。
+s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
+> *"Add a tool, add just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done.
>
-> **Harness 层**: 工具分发 — 扩展模型能触达的边界。
+> **Harness Layer**: Tool Dispatch — Expanding the model's reach.
---
-## 只有 bash 一个工具
+## Only One Tool: Bash
-s01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo "..." > file.py`,改文件要 `sed`。
+The s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo "..." > file.py`; to edit, `sed`.
-模型想的是"读这个文件",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。
+The model thinks "read this file" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.
---
-## 全局视角:工具分发
+## Overview: Tool Dispatch
-
+
-s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。
+The s01 loop is fully preserved (LLM call, stop_reason check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.
-给 Agent 加一个工具只需要做两件事:
+Adding a tool to the Agent requires just two things:
-1. **定义工具**:在 `TOOLS` 数组里加一条描述
-2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射
+1. **Define the tool**: Add one entry to the `TOOLS` array
+2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict
---
-## 从 1 个工具到 5 个工具
+## From 1 Tool to 5 Tools
-s01 只有一个 bash:
+s01 had only bash:
```python
TOOLS = [{"name": "bash", ...}]
@@ -40,7 +40,7 @@ TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
```
-s02 加到 5 个,每个工具都是独立定义:
+s02 expands to 5 tools, each independently defined:
```python
TOOLS = [
@@ -52,7 +52,7 @@ TOOLS = [
]
```
-每个工具有自己的实现函数:
+Each tool has its own implementation function:
```python
def run_read(path, limit=None):
@@ -79,7 +79,7 @@ def run_glob(pattern):
---
-## 工具分发
+## Tool Dispatch
```python
TOOL_HANDLERS = {
@@ -90,133 +90,133 @@ TOOL_HANDLERS = {
"glob": run_glob,
}
-# 循环里只改了一行——从硬编码 run_bash 变成查表:
+# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:
for block in response.content:
if block.type == "tool_use":
- handler = TOOL_HANDLERS[block.name] # 查表
- output = handler(**block.input) # 调用
+ handler = TOOL_HANDLERS[block.name] # lookup
+ output = handler(**block.input) # call
results.append(...)
```
-加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。
+Adding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same.
---
-## 多个工具调用
+## Multiple Tool Calls
-模型经常一次返回多个 tool_use:"读一下 a.py 和 b.py,然后列出所有 .py 文件"。
+The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files".
-教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。
+The teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).
---
-## 速查
+## Quick Reference
-| 概念 | 一句话 |
-|------|--------|
-| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |
-| 工具定义 | 告诉模型"我能做什么"的 JSON schema |
-| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |
-| 循环不变 | s01 的 `while True` 循环一行都没改 |
+| Concept | One-Liner |
+|---------|-----------|
+| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |
+| Tool Definition | JSON schema telling the model "what I can do" |
+| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |
+| Loop Unchanged | s01's `while True` loop — not a single line changed |
---
-## 相对 s01 的变更
+## Changes from s01
-| 组件 | 之前 (s01) | 之后 (s02) |
-|------|-----------|-----------|
-| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |
-| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |
-| 路径安全 | 无 | safe_path 校验(仅 file tools) |
-| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 |
+| Component | Before (s01) | After (s02) |
+|-----------|-------------|-------------|
+| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |
+| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |
+| Path safety | None | safe_path validation (file tools only) |
+| Loop | `while True` + `stop_reason` | Identical to s01 |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s02_tool_use/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Read the file README.md and tell me what this project is about`
2. `Create a file called test.py that prints "hello", then read it back`
3. `Find all Python files in this directory`
4. `Read both README.md and requirements.txt, then create a summary file`
-观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?
+What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?
---
-## 接下来
+## What's Next
-现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。
+The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.
-s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?
+→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?
-深入 CC 源码
+Dive into CC Source Code
-> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。
+> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.
-### 一、工具定义方式
+### 1. Tool Definition Approach
-**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。
-**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。
+**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.
+**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.
-教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。
+The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions".
-### 二、并发安全判断:isConcurrencySafe()
+### 2. Concurrency Safety: isConcurrencySafe()
-
+
-教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:
+The teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
-| Bash `ls` | true | **true** ← 关键差异 |
+| Bash `ls` | true | **true** ← key difference |
| Bash `rm` | false | false |
-| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |
+| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |
-CC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。
+CC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.
-### 三、分区算法
+### 3. Partition Algorithm
-CC 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:
+CC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:
```
[read A, read B, glob *.py, bash "rm x", read C]
- → batch1(并发): [read A, read B, glob *.py]
- → batch2(串行): [bash "rm x"]
- → batch3(并发): [read C]
+ → batch1(concurrent): [read A, read B, glob *.py]
+ → batch2(serial): [bash "rm x"]
+ → batch3(concurrent): [read C]
```
-并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。
+Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.
-### 四、验证管线
+### 4. Validation Pipeline
-CC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):
+Each tool call in CC goes through a strict 5-step validation (`toolExecution.ts`):
-1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查
-2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内)
-3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行
-4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask
-5. **执行 tool.call()**(`1207-1222`)
+1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check
+2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)
+3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution
+4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask
+5. **Execute tool.call()** (`1207-1222`)
-教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。
+The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.
-### 五、流式工具执行
+### 5. Streaming Tool Execution
-CC 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。
+CC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.
-### 六、工具结果持久化
+### 6. Tool Result Persistence
-每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。
+Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).
-
+
diff --git a/s02_tool_use/README.zh.md b/s02_tool_use/README.zh.md
new file mode 100644
index 00000000..fee9d07e
--- /dev/null
+++ b/s02_tool_use/README.zh.md
@@ -0,0 +1,222 @@
+# s02: Tool Use — 多加一个工具,只加一行
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
+> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。
+>
+> **Harness 层**: 工具分发 — 扩展模型能触达的边界。
+
+---
+
+## 只有 bash 一个工具
+
+s01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo "..." > file.py`,改文件要 `sed`。
+
+模型想的是"读这个文件",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。
+
+---
+
+## 全局视角:工具分发
+
+
+
+s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。
+
+给 Agent 加一个工具只需要做两件事:
+
+1. **定义工具**:在 `TOOLS` 数组里加一条描述
+2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射
+
+---
+
+## 从 1 个工具到 5 个工具
+
+s01 只有一个 bash:
+
+```python
+TOOLS = [{"name": "bash", ...}]
+
+def run_bash(command): ...
+```
+
+s02 加到 5 个,每个工具都是独立定义:
+
+```python
+TOOLS = [
+ {"name": "bash", "description": "Run a shell command.", ...},
+ {"name": "read_file", "description": "Read file contents.", ...},
+ {"name": "write_file", "description": "Write content to file.", ...},
+ {"name": "edit_file", "description": "Replace text in file once.", ...},
+ {"name": "glob", "description": "Find files by pattern.", ...},
+]
+```
+
+每个工具有自己的实现函数:
+
+```python
+def run_read(path, limit=None):
+ lines = safe_path(path).read_text().splitlines()
+ if limit:
+ lines = lines[:limit]
+ return "\n".join(lines)
+
+def run_write(path, content):
+ safe_path(path).write_text(content)
+ return f"Wrote {len(content)} bytes to {path}"
+
+def run_edit(path, old_text, new_text):
+ text = safe_path(path).read_text()
+ if old_text not in text:
+ return "Error: text not found"
+ safe_path(path).write_text(text.replace(old_text, new_text, 1))
+ return f"Edited {path}"
+
+def run_glob(pattern):
+ import glob as g
+ return "\n".join(g.glob(pattern, root_dir=WORKDIR))
+```
+
+---
+
+## 工具分发
+
+```python
+TOOL_HANDLERS = {
+ "bash": run_bash,
+ "read_file": run_read,
+ "write_file": run_write,
+ "edit_file": run_edit,
+ "glob": run_glob,
+}
+
+# 循环里只改了一行——从硬编码 run_bash 变成查表:
+for block in response.content:
+ if block.type == "tool_use":
+ handler = TOOL_HANDLERS[block.name] # 查表
+ output = handler(**block.input) # 调用
+ results.append(...)
+```
+
+加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。
+
+---
+
+## 多个工具调用
+
+模型经常一次返回多个 tool_use:"读一下 a.py 和 b.py,然后列出所有 .py 文件"。
+
+教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。
+
+---
+
+## 速查
+
+| 概念 | 一句话 |
+|------|--------|
+| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |
+| 工具定义 | 告诉模型"我能做什么"的 JSON schema |
+| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |
+| 循环不变 | s01 的 `while True` 循环一行都没改 |
+
+---
+
+## 相对 s01 的变更
+
+| 组件 | 之前 (s01) | 之后 (s02) |
+|------|-----------|-----------|
+| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |
+| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |
+| 路径安全 | 无 | safe_path 校验(仅 file tools) |
+| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s02_tool_use/code.py
+```
+
+试试这些 prompt:
+
+1. `Read the file README.md and tell me what this project is about`
+2. `Create a file called test.py that prints "hello", then read it back`
+3. `Find all Python files in this directory`
+4. `Read both README.md and requirements.txt, then create a summary file`
+
+观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?
+
+---
+
+## 接下来
+
+现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。
+
+s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。
+
+### 一、工具定义方式
+
+**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。
+**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。
+
+教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。
+
+### 二、并发安全判断:isConcurrencySafe()
+
+
+
+教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:
+
+| | isReadOnly | isConcurrencySafe |
+|---|---|---|
+| FileRead | true | true |
+| Glob | true | true |
+| Bash `ls` | true | **true** ← 关键差异 |
+| Bash `rm` | false | false |
+| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |
+
+CC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。
+
+### 三、分区算法
+
+CC 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:
+
+```
+[read A, read B, glob *.py, bash "rm x", read C]
+ → batch1(并发): [read A, read B, glob *.py]
+ → batch2(串行): [bash "rm x"]
+ → batch3(并发): [read C]
+```
+
+并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。
+
+### 四、验证管线
+
+CC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):
+
+1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查
+2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内)
+3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行
+4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask
+5. **执行 tool.call()**(`1207-1222`)
+
+教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。
+
+### 五、流式工具执行
+
+CC 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。
+
+### 六、工具结果持久化
+
+每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。
+
+
+
+
diff --git a/s03_permission/README.en.md b/s03_permission/README.en.md
deleted file mode 100644
index 60451679..00000000
--- a/s03_permission/README.en.md
+++ /dev/null
@@ -1,232 +0,0 @@
-# s03: Permission — Check Permissions Before Execution
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
-> *"Check permissions before executing"* — The permission pipeline decides which operations need approval.
->
-> **Harness Layer**: Permission — a gate before tool execution.
-
----
-
-## The Problem
-
-s02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to "clean up the project," and it might run `rm -rf /`.
-
-Safety can't rely on trusting the model — it needs code: a check before every tool execution.
-
----
-
-## The Solution
-
-
-
-s02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.
-
-The three gates correspond to three decisions:
-
-| Gate | Purpose | On Match |
-|------|---------|----------|
-| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |
-| 2. Rule Matching | Context-dependent operations (reading/writing outside workspace, `rm` files) | Passed to Gate 3 |
-| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |
-
-None of the three gates match → execute directly. Most routine operations take this path.
-
----
-
-## How It Works
-
-
-
-**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)
-
-```python
-DENY_LIST = [
- "rm -rf /", "sudo", "shutdown", "reboot",
- "mkfs", "dd if=", "> /dev/sda",
-]
-
-def check_deny_list(command: str) -> str | None:
- for pattern in DENY_LIST:
- if pattern in command:
- return f"Blocked: '{pattern}' is on the deny list"
- return None
-```
-
-**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.
-
-```python
-PERMISSION_RULES = [
- {
- "tools": ["read_file", "write_file", "edit_file"],
- "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
- "message": "Access outside workspace",
- },
- {
- "tools": ["bash"],
- "check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
- "message": "Potentially destructive command",
- },
-]
-
-def check_rules(tool_name: str, args: dict) -> str | None:
- for rule in PERMISSION_RULES:
- if tool_name in rule["tools"] and rule["check"](args):
- return rule["message"]
- return None
-```
-
-**Gate 3**: After a rule matches, pause for user input.
-
-```python
-def ask_user(tool_name: str, args: dict, reason: str) -> str:
- print(f"\n⚠ {reason}")
- print(f" Tool: {tool_name}({args})")
- choice = input(" Allow? [y/N] ").strip().lower()
- return "allow" if choice in ("y", "yes") else "deny"
-```
-
-**All three gates chained together**, inserted before tool execution:
-
-```python
-def check_permission(block) -> bool:
- # Gate 1: Hard deny
- if block.name == "bash":
- reason = check_deny_list(block.input.get("command", ""))
- if reason:
- print(f"\n⛔ {reason}")
- return False
-
- # Gate 2 + 3: Rule matching → User approval
- reason = check_rules(block.name, block.input)
- if reason:
- decision = ask_user(block.name, block.input, reason)
- if decision == "deny":
- return False
-
- return True
-
-# In agent_loop — s02's loop with just one line added:
-for block in response.content:
- if block.type == "tool_use":
- if not check_permission(block): # ← NEW
- results.append({... "content": "Permission denied."})
- continue
- output = TOOL_HANDLERS[block.name](**block.input) # s02 original
- results.append(...)
-```
-
----
-
-## Changes from s02
-
-| Component | Before (s02) | After (s03) |
-|-----------|-------------|-------------|
-| Security model | None (trust the model) | Three-gate permission pipeline |
-| New functions | — | check_deny_list, check_rules, ask_user, check_permission |
-| Loop | Executes all tools directly | Inserts check_permission() before execution |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s03_permission/code.py
-```
-
-Try these prompts:
-
-1. `Create a file called test.txt in the current directory` (should pass through)
-2. `Delete the file test.txt` (bash + rm triggers Gate 2)
-3. `What files are in the current directory?` (read-only, all pass)
-4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)
-
-What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?
-
----
-
-## What's Next
-
-Permission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.
-
-→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.
-
-
-Dive into CC Source Code
-
-> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.
-
-### 1. PermissionResult: Not 3, but 4
-
-The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):
-
-| behavior | Meaning | Teaching Version Equivalent |
-|----------|---------|---------------------------|
-| `allow` | Allow directly | Gate 3 passes |
-| `deny` | Deny directly | Gate 1 matches |
-| `ask` | Show dialog to user | Gate 2 matches |
-| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |
-
-### 2. Production Verification Stages
-
-CC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:
-
-1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking
-2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation
-3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields
-4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask
-5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions
-6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:
- - Entire tool disabled by deny rule → `deny`
- - Entire tool flagged by ask rule → `ask`
- - `tool.checkPermissions()` tool's own judgment
- - Tool itself returns deny → `deny`
- - `requiresUserInteraction()` → `ask`
- - Content-related ask rules → `ask` (not bypassable)
- - Security check violation → `ask` (not bypassable)
- - bypassPermissions mode → `allow`
- - Entire tool allowed by allow rule → `allow`
- - passthrough → converted to `ask`
-
-### 3. Deny List: Not One File, but 8 Sources
-
-CC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):
-
-| Source | Configuration Location |
-|--------|----------------------|
-| `userSettings` | `~/.claude/settings.json` |
-| `projectSettings` | `.claude/settings.json` |
-| `localSettings` | `settings.local.json` |
-| `flagSettings` | Feature flags |
-| `policySettings` | Enterprise management policy |
-| `cliArg` | `--allowedTools` / `--deniedTools` |
-| `command` | Inline command |
-| `session` | In-session temporary authorization |
-
-Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).
-
-### 4. What is isDestructive()
-
-In CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.
-
-### 5. YoloClassifier (Auto-Approval)
-
-In CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.
-
-### 6. Permission Bubbling
-
-A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.
-
-### The Teaching Version's Simplification Is Intentional
-
-- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding
-- 8 rule sources → 1 local DENY_LIST: manageable concept count
-- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)
-- YoloClassifier → omitted (depends on additional LLM calls and telemetry)
-- Permission bubbling → omitted (s15 covers multi-Agent)
-
-
-
-
diff --git a/s03_permission/README.ja.md b/s03_permission/README.ja.md
index 9dc02034..c4362591 100644
--- a/s03_permission/README.ja.md
+++ b/s03_permission/README.ja.md
@@ -1,8 +1,8 @@
# s03: Permission — 実行前に権限を判断する
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
+s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
> *"ツール実行前に権限を判断"* — 権限パイプラインは、どの操作に承認が必要かを決める。
>
> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。
diff --git a/s03_permission/README.md b/s03_permission/README.md
index 0e924f1b..316e7b6d 100644
--- a/s03_permission/README.md
+++ b/s03_permission/README.md
@@ -1,45 +1,45 @@
-# s03: Permission — 执行前做权限判断
+# s03: Permission — Check Permissions Before Execution
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
-> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。
+s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
+> *"Check permissions before executing"* — The permission pipeline decides which operations need approval.
>
-> **Harness 层**: 权限 — 在工具执行前加一道门。
+> **Harness Layer**: Permission — a gate before tool execution.
---
-## 问题
+## The Problem
-s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`。
+s02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to "clean up the project," and it might run `rm -rf /`.
-安全不能靠信任模型,要靠代码——在工具执行之前做判断。
+Safety can't rely on trusting the model — it needs code: a check before every tool execution.
---
-## 解决方案
+## The Solution
-
+
-s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。
+s02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.
-三道闸门对应三种决策:
+The three gates correspond to three decisions:
-| 闸门 | 作用 | 命中后 |
-|------|------|--------|
-| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |
-| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |
-| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
+| Gate | Purpose | On Match |
+|------|---------|----------|
+| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |
+| 2. Rule Matching | Context-dependent operations (reading/writing outside workspace, `rm` files) | Passed to Gate 3 |
+| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |
-三道都没命中 → 直接执行。大部分日常操作走这条路。
+None of the three gates match → execute directly. Most routine operations take this path.
---
-## 工作原理
+## How It Works
-
+
-**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。)
+**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)
```python
DENY_LIST = [
@@ -54,7 +54,7 @@ def check_deny_list(command: str) -> str | None:
return None
```
-**闸门 2**:规则匹配——描述"什么时候需要问用户"。每条规则指定工具和检查条件。
+**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.
```python
PERMISSION_RULES = [
@@ -77,7 +77,7 @@ def check_rules(tool_name: str, args: dict) -> str | None:
return None
```
-**闸门 3**:规则命中后,暂停等用户输入。
+**Gate 3**: After a rule matches, pause for user input.
```python
def ask_user(tool_name: str, args: dict, reason: str) -> str:
@@ -87,18 +87,18 @@ def ask_user(tool_name: str, args: dict, reason: str) -> str:
return "allow" if choice in ("y", "yes") else "deny"
```
-**三道闸门串在一起**,插在工具执行之前:
+**All three gates chained together**, inserted before tool execution:
```python
def check_permission(block) -> bool:
- # 闸门 1: 硬拒绝
+ # Gate 1: Hard deny
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n⛔ {reason}")
return False
- # 闸门 2 + 3: 规则匹配 → 用户审批
+ # Gate 2 + 3: Rule matching → User approval
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
@@ -107,125 +107,125 @@ def check_permission(block) -> bool:
return True
-# 在 agent_loop 中——s02 的循环只加了一行:
+# In agent_loop — s02's loop with just one line added:
for block in response.content:
if block.type == "tool_use":
- if not check_permission(block): # ← 新增
+ if not check_permission(block): # ← NEW
results.append({... "content": "Permission denied."})
continue
- output = TOOL_HANDLERS[block.name](**block.input) # s02 原有
+ output = TOOL_HANDLERS[block.name](**block.input) # s02 original
results.append(...)
```
---
-## 相对 s02 的变更
+## Changes from s02
-| 组件 | 之前 (s02) | 之后 (s03) |
-|------|-----------|-----------|
-| 安全模型 | 无(信任模型) | 三道闸门权限管线 |
-| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |
-| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |
+| Component | Before (s02) | After (s03) |
+|-----------|-------------|-------------|
+| Security model | None (trust the model) | Three-gate permission pipeline |
+| New functions | — | check_deny_list, check_rules, ask_user, check_permission |
+| Loop | Executes all tools directly | Inserts check_permission() before execution |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s03_permission/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. `Create a file called test.txt in the current directory`(应该直接通过)
-2. `Delete the file test.txt`(bash + rm 会触发闸门 2)
-3. `What files are in the current directory?`(只读,全部通过)
-4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)
+1. `Create a file called test.txt in the current directory` (should pass through)
+2. `Delete the file test.txt` (bash + rm triggers Gate 2)
+3. `What files are in the current directory?` (read-only, all pass)
+4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)
-观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
+What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?
---
-## 接下来
+## What's Next
-权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。
+Permission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.
-s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。
+→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.
-深入 CC 源码
+Dive into CC Source Code
-> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。
+> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.
-### 一、PermissionResult:不是 3 种,是 4 种
+### 1. PermissionResult: Not 3, but 4
-教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`):
+The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):
-| behavior | 含义 | 教学版对应 |
-|----------|------|-----------|
-| `allow` | 直接允许 | 闸门 3 通过 |
-| `deny` | 直接拒绝 | 闸门 1 命中 |
-| `ask` | 弹出对话框问用户 | 闸门 2 命中 |
-| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |
+| behavior | Meaning | Teaching Version Equivalent |
+|----------|---------|---------------------------|
+| `allow` | Allow directly | Gate 3 passes |
+| `deny` | Deny directly | Gate 1 matches |
+| `ask` | Show dialog to user | Gate 2 matches |
+| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |
-### 二、生产版的验证阶段
+### 2. Production Verification Stages
-CC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:
+CC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:
-1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查
-2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证
-3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段
-4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask
-5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策
-6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查:
- - 整个工具被 deny rule 禁用 → `deny`
- - 整个工具被 ask rule 标记 → `ask`
- - `tool.checkPermissions()` 工具自己的判断
- - 工具自己返回 deny → `deny`
+1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking
+2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation
+3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields
+4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask
+5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions
+6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:
+ - Entire tool disabled by deny rule → `deny`
+ - Entire tool flagged by ask rule → `ask`
+ - `tool.checkPermissions()` tool's own judgment
+ - Tool itself returns deny → `deny`
- `requiresUserInteraction()` → `ask`
- - 内容相关的 ask 规则 → `ask`(不可绕过)
- - 安全检查违规 → `ask`(不可绕过)
- - bypassPermissions 模式 → `allow`
- - 整个工具被 allow rule 放行 → `allow`
- - passthrough → 转为 `ask`
+ - Content-related ask rules → `ask` (not bypassable)
+ - Security check violation → `ask` (not bypassable)
+ - bypassPermissions mode → `allow`
+ - Entire tool allowed by allow rule → `allow`
+ - passthrough → converted to `ask`
-### 三、拒绝列表:不是一个文件,是 8 个来源
+### 3. Deny List: Not One File, but 8 Sources
-CC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):
+CC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):
-| 来源 | 配置位置 |
-|------|---------|
+| Source | Configuration Location |
+|--------|----------------------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | Feature flags |
-| `policySettings` | 企业管理策略 |
+| `policySettings` | Enterprise management policy |
| `cliArg` | `--allowedTools` / `--deniedTools` |
-| `command` | 内联命令 |
-| `session` | 会话内临时授权 |
+| `command` | Inline command |
+| `session` | In-session temporary authorization |
-每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。
+Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).
-### 四、isDestructive() 是什么
+### 4. What is isDestructive()
-CC 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。
+In CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.
-### 五、YoloClassifier(自动审批)
+### 5. YoloClassifier (Auto-Approval)
-CC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。
+In CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.
-### 六、权限冒泡
+### 6. Permission Bubbling
-子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。
+A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.
-### 教学版的简化是刻意的
+### The Teaching Version's Simplification Is Intentional
-- 多阶段管线 → 3 道闸门:理解门槛大幅降低
-- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控
-- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策)
-- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)
-- 权限冒泡 → 省略(s15 才涉及多 Agent)
+- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding
+- 8 rule sources → 1 local DENY_LIST: manageable concept count
+- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)
+- YoloClassifier → omitted (depends on additional LLM calls and telemetry)
+- Permission bubbling → omitted (s15 covers multi-Agent)
diff --git a/s03_permission/README.zh.md b/s03_permission/README.zh.md
new file mode 100644
index 00000000..01643121
--- /dev/null
+++ b/s03_permission/README.zh.md
@@ -0,0 +1,232 @@
+# s03: Permission — 执行前做权限判断
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
+> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。
+>
+> **Harness 层**: 权限 — 在工具执行前加一道门。
+
+---
+
+## 问题
+
+s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`。
+
+安全不能靠信任模型,要靠代码——在工具执行之前做判断。
+
+---
+
+## 解决方案
+
+
+
+s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。
+
+三道闸门对应三种决策:
+
+| 闸门 | 作用 | 命中后 |
+|------|------|--------|
+| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |
+| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |
+| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
+
+三道都没命中 → 直接执行。大部分日常操作走这条路。
+
+---
+
+## 工作原理
+
+
+
+**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。)
+
+```python
+DENY_LIST = [
+ "rm -rf /", "sudo", "shutdown", "reboot",
+ "mkfs", "dd if=", "> /dev/sda",
+]
+
+def check_deny_list(command: str) -> str | None:
+ for pattern in DENY_LIST:
+ if pattern in command:
+ return f"Blocked: '{pattern}' is on the deny list"
+ return None
+```
+
+**闸门 2**:规则匹配——描述"什么时候需要问用户"。每条规则指定工具和检查条件。
+
+```python
+PERMISSION_RULES = [
+ {
+ "tools": ["read_file", "write_file", "edit_file"],
+ "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
+ "message": "Access outside workspace",
+ },
+ {
+ "tools": ["bash"],
+ "check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
+ "message": "Potentially destructive command",
+ },
+]
+
+def check_rules(tool_name: str, args: dict) -> str | None:
+ for rule in PERMISSION_RULES:
+ if tool_name in rule["tools"] and rule["check"](args):
+ return rule["message"]
+ return None
+```
+
+**闸门 3**:规则命中后,暂停等用户输入。
+
+```python
+def ask_user(tool_name: str, args: dict, reason: str) -> str:
+ print(f"\n⚠ {reason}")
+ print(f" Tool: {tool_name}({args})")
+ choice = input(" Allow? [y/N] ").strip().lower()
+ return "allow" if choice in ("y", "yes") else "deny"
+```
+
+**三道闸门串在一起**,插在工具执行之前:
+
+```python
+def check_permission(block) -> bool:
+ # 闸门 1: 硬拒绝
+ if block.name == "bash":
+ reason = check_deny_list(block.input.get("command", ""))
+ if reason:
+ print(f"\n⛔ {reason}")
+ return False
+
+ # 闸门 2 + 3: 规则匹配 → 用户审批
+ reason = check_rules(block.name, block.input)
+ if reason:
+ decision = ask_user(block.name, block.input, reason)
+ if decision == "deny":
+ return False
+
+ return True
+
+# 在 agent_loop 中——s02 的循环只加了一行:
+for block in response.content:
+ if block.type == "tool_use":
+ if not check_permission(block): # ← 新增
+ results.append({... "content": "Permission denied."})
+ continue
+ output = TOOL_HANDLERS[block.name](**block.input) # s02 原有
+ results.append(...)
+```
+
+---
+
+## 相对 s02 的变更
+
+| 组件 | 之前 (s02) | 之后 (s03) |
+|------|-----------|-----------|
+| 安全模型 | 无(信任模型) | 三道闸门权限管线 |
+| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |
+| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s03_permission/code.py
+```
+
+试试这些 prompt:
+
+1. `Create a file called test.txt in the current directory`(应该直接通过)
+2. `Delete the file test.txt`(bash + rm 会触发闸门 2)
+3. `What files are in the current directory?`(只读,全部通过)
+4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)
+
+观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
+
+---
+
+## 接下来
+
+权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。
+
+s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。
+
+### 一、PermissionResult:不是 3 种,是 4 种
+
+教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`):
+
+| behavior | 含义 | 教学版对应 |
+|----------|------|-----------|
+| `allow` | 直接允许 | 闸门 3 通过 |
+| `deny` | 直接拒绝 | 闸门 1 命中 |
+| `ask` | 弹出对话框问用户 | 闸门 2 命中 |
+| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |
+
+### 二、生产版的验证阶段
+
+CC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:
+
+1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查
+2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证
+3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段
+4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask
+5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策
+6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查:
+ - 整个工具被 deny rule 禁用 → `deny`
+ - 整个工具被 ask rule 标记 → `ask`
+ - `tool.checkPermissions()` 工具自己的判断
+ - 工具自己返回 deny → `deny`
+ - `requiresUserInteraction()` → `ask`
+ - 内容相关的 ask 规则 → `ask`(不可绕过)
+ - 安全检查违规 → `ask`(不可绕过)
+ - bypassPermissions 模式 → `allow`
+ - 整个工具被 allow rule 放行 → `allow`
+ - passthrough → 转为 `ask`
+
+### 三、拒绝列表:不是一个文件,是 8 个来源
+
+CC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):
+
+| 来源 | 配置位置 |
+|------|---------|
+| `userSettings` | `~/.claude/settings.json` |
+| `projectSettings` | `.claude/settings.json` |
+| `localSettings` | `settings.local.json` |
+| `flagSettings` | Feature flags |
+| `policySettings` | 企业管理策略 |
+| `cliArg` | `--allowedTools` / `--deniedTools` |
+| `command` | 内联命令 |
+| `session` | 会话内临时授权 |
+
+每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。
+
+### 四、isDestructive() 是什么
+
+CC 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。
+
+### 五、YoloClassifier(自动审批)
+
+CC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。
+
+### 六、权限冒泡
+
+子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。
+
+### 教学版的简化是刻意的
+
+- 多阶段管线 → 3 道闸门:理解门槛大幅降低
+- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控
+- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策)
+- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)
+- 权限冒泡 → 省略(s15 才涉及多 Agent)
+
+
+
+
diff --git a/s04_hooks/README.en.md b/s04_hooks/README.en.md
deleted file mode 100644
index eab401d4..00000000
--- a/s04_hooks/README.en.md
+++ /dev/null
@@ -1,283 +0,0 @@
-# s04: Hooks — Hang on the Loop, Don't Write into It
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
-
-> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
->
-> **Harness Layer**: Hooks — Extension points that don't invade the loop.
-
----
-
-## The Problem
-
-The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function.
-
-The loop quickly becomes this:
-
-```python
-def agent_loop(messages):
- while True:
- # ... LLM call ...
- for block in response.content:
- if block.type != "tool_use":
- continue
- log_to_file(block) # added a line
- check_permission(block) # added a line
- notify_slack(block) # added another line
- output = execute(block)
- auto_git_add(block) # yet another line
- # ... the loop is unrecognizable
-```
-
-What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.
-
----
-
-## The Solution
-
-
-
-The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run.
-
-Four events, covering a complete agent cycle:
-
-| Event | Trigger Timing | Typical Use |
-|-------|---------------|-------------|
-| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |
-| PreToolUse | Before tool execution | Permission checks, logging |
-| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |
-| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |
-
-Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.
-
----
-
-## How It Works
-
-**Hook registry**: a dict mapping event names to callback lists.
-
-```python
-HOOKS = {
- "UserPromptSubmit": [],
- "PreToolUse": [],
- "PostToolUse": [],
- "Stop": [],
-}
-
-def register_hook(event: str, callback):
- HOOKS[event].append(callback)
-
-def trigger_hooks(event: str, *args):
- for callback in HOOKS[event]:
- result = callback(*args)
- if result is not None: # return value ≠ None → hook says "stop"
- return result
- return None
-```
-
-In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
-
-**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
-
-```python
-def context_inject_hook(query: str) -> str | None:
- """Inject current working directory info into every prompt."""
- print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
- return None # return None = no modification, let prompt through
-
-register_hook("UserPromptSubmit", context_inject_hook)
-```
-
-In the main loop, triggered right after user input:
-
-```python
-query = input("s04 >> ")
-trigger_hooks("UserPromptSubmit", query) # ← before entering LLM
-history.append({"role": "user", "content": query})
-agent_loop(history)
-```
-
-**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
-
-```python
-# PreToolUse: permission check (s03 logic, moved from loop to hook)
-def permission_hook(block):
- if block.name == "bash":
- for pattern in DENY_LIST:
- if pattern in block.input.get("command", ""):
- return "Permission denied by deny list"
- if block.name in ("read_file", "write_file", "edit_file"):
- path = block.input.get("path", "")
- if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
- choice = input(" Allow? [y/N] ").strip().lower()
- if choice not in ("y", "yes"):
- return "Permission denied by user"
- return None
-
-# PreToolUse: logging
-def log_hook(block):
- print(f"[HOOK] {block.name}(...)")
-
-# PostToolUse: large output reminder
-def large_output_hook(block, output):
- if len(str(output)) > 100000:
- print(f"[HOOK] ⚠ Large output from {block.name}")
-
-register_hook("PreToolUse", permission_hook)
-register_hook("PreToolUse", log_hook)
-register_hook("PostToolUse", large_output_hook)
-```
-
-**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary:
-
-```python
-def summary_hook(messages: list) -> str | None:
- """Print a summary when the loop is about to stop."""
- tool_count = sum(1 for m in messages
- for b in (m.get("content") if isinstance(m.get("content"), list) else [])
- if isinstance(b, dict) and b.get("type") == "tool_result")
- print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
- return None # return None = allow stop, return string = force continuation
-
-register_hook("Stop", summary_hook)
-```
-
-In agent_loop, triggered before exit:
-
-```python
-if response.stop_reason != "tool_use":
- force = trigger_hooks("Stop", messages) # ← before exiting
- if force:
- # hook returned a message → inject it and continue
- messages.append({"role": "user", "content": force})
- continue
- return
-```
-
-**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`:
-
-```python
-for block in response.content:
- if block.type != "tool_use":
- continue
-
- # s03: if not check_permission(block): ...
- # s04: hooks replace hardcoding
- blocked = trigger_hooks("PreToolUse", block)
- if blocked:
- results.append({"type": "tool_result", "tool_use_id": block.id,
- "content": str(blocked)})
- continue
-
- handler = TOOL_HANDLERS.get(block.name)
- output = handler(**block.input) if handler else f"Unknown: {block.name}"
-
- trigger_hooks("PostToolUse", block, output)
-
- results.append({"type": "tool_result", "tool_use_id": block.id,
- "content": output})
-```
-
-Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.
-
----
-
-## Changes from s03
-
-| Component | Before (s03) | After (s04) |
-|-----------|-------------|-------------|
-| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |
-| New functions | — | register_hook, trigger_hooks |
-| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
-| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) |
-| Exit control | None | trigger_hooks("Stop", ...) can prevent exit |
-| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s04_hooks/code.py
-```
-
-Try these prompts:
-
-1. `Read the file README.md` (should pass directly, observe hook logs)
-2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)
-3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)
-
-What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?
-
----
-
-## What's Next
-
-The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first?
-
-→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
-
-
-Dive into CC Source Code
-
-> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.
-
-### 1. Hook Events: Not Just 4, but 27
-
-The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):
-
-| Category | Events |
-|----------|--------|
-| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
-| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
-| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
-| Sub-agents | `SubagentStart`, `SubagentStop` |
-| Compaction-related | `PreCompact`, `PostCompact` |
-| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
-| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
-
-The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
-
-### 2. HookResult Common Fields
-
-CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:
-
-| Field | Type | Purpose |
-|-------|------|---------|
-| `message` | Message | Optional UI message |
-| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |
-| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |
-| `preventContinuation` | boolean | Prevent subsequent execution |
-| `stopReason` | string | Stop reason description |
-| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |
-| `updatedInput` | Record | Modify tool input |
-| `additionalContext` | string | Additional context |
-| `updatedMCPToolOutput` | unknown | MCP tool output modification |
-
-### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
-
-This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
-
-The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
-
-### 4. stopHookActive Mechanism
-
-CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
-
-### 5. hook_stopped_continuation
-
-When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
-
-### Teaching Version Simplifications Are Intentional
-
-- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
-- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
-- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
-- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
-
-
-
-
diff --git a/s04_hooks/README.ja.md b/s04_hooks/README.ja.md
index 3185f70a..d8dafae3 100644
--- a/s04_hooks/README.ja.md
+++ b/s04_hooks/README.ja.md
@@ -1,8 +1,8 @@
# s04: Hooks — ループに掛ける、ループには書き込まない
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
+s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
> *"ループに掛ける、ループには書き込まない"* — フックがツール実行の前後に拡張ロジックを注入する。
>
diff --git a/s04_hooks/README.md b/s04_hooks/README.md
index 3b87c4c3..aaa6c3b1 100644
--- a/s04_hooks/README.md
+++ b/s04_hooks/README.md
@@ -1,20 +1,20 @@
-# s04: Hooks — 挂在循环上,不写进循环里
+# s04: Hooks — Hang on the Loop, Don't Write into It
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
+s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
-> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
+> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
>
-> **Harness 层**: hook — 扩展点不侵入循环。
+> **Harness Layer**: Hooks — Extension points that don't invade the loop.
---
-## 问题
+## The Problem
-s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。
+The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function.
-循环很快就变成了这样:
+The loop quickly becomes this:
```python
def agent_loop(messages):
@@ -23,40 +23,40 @@ def agent_loop(messages):
for block in response.content:
if block.type != "tool_use":
continue
- log_to_file(block) # 加一行
- check_permission(block) # 加一行
- notify_slack(block) # 又加一行
+ log_to_file(block) # added a line
+ check_permission(block) # added a line
+ notify_slack(block) # added another line
output = execute(block)
- auto_git_add(block) # 再加一行
- # ... 很快循环就认不出来了
+ auto_git_add(block) # yet another line
+ # ... the loop is unrecognizable
```
-你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。
+What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.
---
-## 解决方案
+## The Solution
-
+
-s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。
+The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run.
-四个事件,覆盖一个完整的 agent cycle:
+Four events, covering a complete agent cycle:
-| 事件 | 触发时机 | 典型用途 |
-|------|---------|---------|
-| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |
-| PreToolUse | 工具执行前 | 权限检查、日志记录 |
-| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |
-| Stop | 循环即将退出时 | 收尾清理(CC 还支持强制续跑) |
+| Event | Trigger Timing | Typical Use |
+|-------|---------------|-------------|
+| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |
+| PreToolUse | Before tool execution | Permission checks, logging |
+| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |
+| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |
-扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。
+Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.
---
-## 工作原理
+## How It Works
-**hook 注册表**:一个字典,事件名映射到回调列表。
+**Hook registry**: a dict mapping event names to callback lists.
```python
HOOKS = {
@@ -72,14 +72,14 @@ def register_hook(event: str, callback):
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
- if result is not None: # 返回值 ≠ None → hook 说"停"
+ if result is not None: # return value ≠ None → hook says "stop"
return result
return None
```
-教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。
+In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
-**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:
+**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
```python
def context_inject_hook(query: str) -> str | None:
@@ -90,19 +90,19 @@ def context_inject_hook(query: str) -> str | None:
register_hook("UserPromptSubmit", context_inject_hook)
```
-在主循环中,用户输入后立即触发:
+In the main loop, triggered right after user input:
```python
query = input("s04 >> ")
-trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前
+trigger_hooks("UserPromptSubmit", query) # ← before entering LLM
history.append({"role": "user", "content": query})
agent_loop(history)
```
-**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:
+**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
```python
-# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)
+# PreToolUse: permission check (s03 logic, moved from loop to hook)
def permission_hook(block):
if block.name == "bash":
for pattern in DENY_LIST:
@@ -116,11 +116,11 @@ def permission_hook(block):
return "Permission denied by user"
return None
-# PreToolUse: 日志
+# PreToolUse: logging
def log_hook(block):
print(f"[HOOK] {block.name}(...)")
-# PostToolUse: 大文件提醒
+# PostToolUse: large output reminder
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[HOOK] ⚠ Large output from {block.name}")
@@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
-**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计:
+**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary:
```python
def summary_hook(messages: list) -> str | None:
@@ -144,11 +144,11 @@ def summary_hook(messages: list) -> str | None:
register_hook("Stop", summary_hook)
```
-在 agent_loop 中,退出前触发:
+In agent_loop, triggered before exit:
```python
if response.stop_reason != "tool_use":
- force = trigger_hooks("Stop", messages) # ← 退出之前
+ force = trigger_hooks("Stop", messages) # ← before exiting
if force:
# hook returned a message → inject it and continue
messages.append({"role": "user", "content": force})
@@ -156,7 +156,7 @@ if response.stop_reason != "tool_use":
return
```
-**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks("PreToolUse", block)`:
+**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`:
```python
for block in response.content:
@@ -164,7 +164,7 @@ for block in response.content:
continue
# s03: if not check_permission(block): ...
- # s04: hook 替代硬编码
+ # s04: hooks replace hardcoding
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
@@ -180,104 +180,104 @@ for block in response.content:
"content": output})
```
-四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。
+Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.
---
-## 相对 s03 的变更
+## Changes from s03
-| 组件 | 之前 (s03) | 之后 (s04) |
-|------|-----------|-----------|
-| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |
-| 新函数 | — | register_hook, trigger_hooks |
-| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
-| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) |
-| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 |
-| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 |
+| Component | Before (s03) | After (s04) |
+|-----------|-------------|-------------|
+| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |
+| New functions | — | register_hook, trigger_hooks |
+| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
+| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) |
+| Exit control | None | trigger_hooks("Stop", ...) can prevent exit |
+| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s04_hooks/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. `Read the file README.md`(应该直接通过,观察 hook 日志)
-2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)
-3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook)
+1. `Read the file README.md` (should pass directly, observe hook logs)
+2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)
+3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)
-观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?
+What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?
---
-## 接下来
+## What's Next
-Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划?
+The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first?
-s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。
+→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
-深入 CC 源码
+Dive into CC Source Code
-> 以下基于 CC 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。
+> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.
-### 一、Hook 事件:不止这 4 个,而是 27 个
+### 1. Hook Events: Not Just 4, but 27
-教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):
+The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):
-| 类别 | 事件 |
-|------|------|
-| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
-| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
-| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
-| 子 Agent | `SubagentStart`, `SubagentStop` |
-| 压缩相关 | `PreCompact`, `PostCompact` |
-| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
-| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
+| Category | Events |
+|----------|--------|
+| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
+| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
+| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
+| Sub-agents | `SubagentStart`, `SubagentStop` |
+| Compaction-related | `PreCompact`, `PostCompact` |
+| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
+| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
-教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。
+The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
-### 二、HookResult 常用字段摘录
+### 2. HookResult Common Fields
-CC 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:
+CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:
-| 字段 | 类型 | 用途 |
-|------|------|------|
-| `message` | Message | 可选 UI 消息 |
-| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |
-| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |
-| `preventContinuation` | boolean | 阻止后续执行 |
-| `stopReason` | string | 停止原因描述 |
-| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |
-| `updatedInput` | Record | 修改工具输入 |
-| `additionalContext` | string | 附加上下文 |
-| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |
+| Field | Type | Purpose |
+|-------|------|---------|
+| `message` | Message | Optional UI message |
+| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |
+| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |
+| `preventContinuation` | boolean | Prevent subsequent execution |
+| `stopReason` | string | Stop reason description |
+| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |
+| `updatedInput` | Record | Modify tool input |
+| `additionalContext` | string | Additional context |
+| `updatedMCPToolOutput` | unknown | MCP tool output modification |
-### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则
+### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
-这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。
+This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
-教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。
+The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
-### 四、stopHookActive 机制
+### 4. stopHookActive Mechanism
-CC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...
+CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
-### 五、hook_stopped_continuation
+### 5. hook_stopped_continuation
-PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。
+When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
-### 教学版的简化是刻意的
+### Teaching Version Simplifications Are Intentional
-- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点
-- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低
-- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层
-- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制
+- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
+- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
+- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
+- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
-
+
diff --git a/s04_hooks/README.zh.md b/s04_hooks/README.zh.md
new file mode 100644
index 00000000..d465ff35
--- /dev/null
+++ b/s04_hooks/README.zh.md
@@ -0,0 +1,283 @@
+# s04: Hooks — 挂在循环上,不写进循环里
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
+
+> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
+>
+> **Harness 层**: hook — 扩展点不侵入循环。
+
+---
+
+## 问题
+
+s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。
+
+循环很快就变成了这样:
+
+```python
+def agent_loop(messages):
+ while True:
+ # ... LLM call ...
+ for block in response.content:
+ if block.type != "tool_use":
+ continue
+ log_to_file(block) # 加一行
+ check_permission(block) # 加一行
+ notify_slack(block) # 又加一行
+ output = execute(block)
+ auto_git_add(block) # 再加一行
+ # ... 很快循环就认不出来了
+```
+
+你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。
+
+---
+
+## 解决方案
+
+
+
+s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。
+
+四个事件,覆盖一个完整的 agent cycle:
+
+| 事件 | 触发时机 | 典型用途 |
+|------|---------|---------|
+| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |
+| PreToolUse | 工具执行前 | 权限检查、日志记录 |
+| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |
+| Stop | 循环即将退出时 | 收尾清理(CC 还支持强制续跑) |
+
+扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。
+
+---
+
+## 工作原理
+
+**hook 注册表**:一个字典,事件名映射到回调列表。
+
+```python
+HOOKS = {
+ "UserPromptSubmit": [],
+ "PreToolUse": [],
+ "PostToolUse": [],
+ "Stop": [],
+}
+
+def register_hook(event: str, callback):
+ HOOKS[event].append(callback)
+
+def trigger_hooks(event: str, *args):
+ for callback in HOOKS[event]:
+ result = callback(*args)
+ if result is not None: # 返回值 ≠ None → hook 说"停"
+ return result
+ return None
+```
+
+教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。
+
+**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:
+
+```python
+def context_inject_hook(query: str) -> str | None:
+ """Inject current working directory info into every prompt."""
+ print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
+ return None # return None = no modification, let prompt through
+
+register_hook("UserPromptSubmit", context_inject_hook)
+```
+
+在主循环中,用户输入后立即触发:
+
+```python
+query = input("s04 >> ")
+trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前
+history.append({"role": "user", "content": query})
+agent_loop(history)
+```
+
+**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:
+
+```python
+# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)
+def permission_hook(block):
+ if block.name == "bash":
+ for pattern in DENY_LIST:
+ if pattern in block.input.get("command", ""):
+ return "Permission denied by deny list"
+ if block.name in ("read_file", "write_file", "edit_file"):
+ path = block.input.get("path", "")
+ if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
+ choice = input(" Allow? [y/N] ").strip().lower()
+ if choice not in ("y", "yes"):
+ return "Permission denied by user"
+ return None
+
+# PreToolUse: 日志
+def log_hook(block):
+ print(f"[HOOK] {block.name}(...)")
+
+# PostToolUse: 大文件提醒
+def large_output_hook(block, output):
+ if len(str(output)) > 100000:
+ print(f"[HOOK] ⚠ Large output from {block.name}")
+
+register_hook("PreToolUse", permission_hook)
+register_hook("PreToolUse", log_hook)
+register_hook("PostToolUse", large_output_hook)
+```
+
+**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计:
+
+```python
+def summary_hook(messages: list) -> str | None:
+ """Print a summary when the loop is about to stop."""
+ tool_count = sum(1 for m in messages
+ for b in (m.get("content") if isinstance(m.get("content"), list) else [])
+ if isinstance(b, dict) and b.get("type") == "tool_result")
+ print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
+ return None # return None = allow stop, return string = force continuation
+
+register_hook("Stop", summary_hook)
+```
+
+在 agent_loop 中,退出前触发:
+
+```python
+if response.stop_reason != "tool_use":
+ force = trigger_hooks("Stop", messages) # ← 退出之前
+ if force:
+ # hook returned a message → inject it and continue
+ messages.append({"role": "user", "content": force})
+ continue
+ return
+```
+
+**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks("PreToolUse", block)`:
+
+```python
+for block in response.content:
+ if block.type != "tool_use":
+ continue
+
+ # s03: if not check_permission(block): ...
+ # s04: hook 替代硬编码
+ blocked = trigger_hooks("PreToolUse", block)
+ if blocked:
+ results.append({"type": "tool_result", "tool_use_id": block.id,
+ "content": str(blocked)})
+ continue
+
+ handler = TOOL_HANDLERS.get(block.name)
+ output = handler(**block.input) if handler else f"Unknown: {block.name}"
+
+ trigger_hooks("PostToolUse", block, output)
+
+ results.append({"type": "tool_result", "tool_use_id": block.id,
+ "content": output})
+```
+
+四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。
+
+---
+
+## 相对 s03 的变更
+
+| 组件 | 之前 (s03) | 之后 (s04) |
+|------|-----------|-----------|
+| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |
+| 新函数 | — | register_hook, trigger_hooks |
+| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
+| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) |
+| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 |
+| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s04_hooks/code.py
+```
+
+试试这些 prompt:
+
+1. `Read the file README.md`(应该直接通过,观察 hook 日志)
+2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)
+3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook)
+
+观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?
+
+---
+
+## 接下来
+
+Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划?
+
+s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。
+
+### 一、Hook 事件:不止这 4 个,而是 27 个
+
+教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):
+
+| 类别 | 事件 |
+|------|------|
+| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
+| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
+| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
+| 子 Agent | `SubagentStart`, `SubagentStop` |
+| 压缩相关 | `PreCompact`, `PostCompact` |
+| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
+| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
+
+教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。
+
+### 二、HookResult 常用字段摘录
+
+CC 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:
+
+| 字段 | 类型 | 用途 |
+|------|------|------|
+| `message` | Message | 可选 UI 消息 |
+| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |
+| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |
+| `preventContinuation` | boolean | 阻止后续执行 |
+| `stopReason` | string | 停止原因描述 |
+| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |
+| `updatedInput` | Record | 修改工具输入 |
+| `additionalContext` | string | 附加上下文 |
+| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |
+
+### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则
+
+这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。
+
+教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。
+
+### 四、stopHookActive 机制
+
+CC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...
+
+### 五、hook_stopped_continuation
+
+PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。
+
+### 教学版的简化是刻意的
+
+- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点
+- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低
+- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层
+- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制
+
+
+
+
diff --git a/s05_todo_write/README.en.md b/s05_todo_write/README.en.md
deleted file mode 100644
index 88c526cc..00000000
--- a/s05_todo_write/README.en.md
+++ /dev/null
@@ -1,158 +0,0 @@
-# s05: TodoWrite — An Agent Without a Plan Drifts Off Course
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
-
-> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps.
->
-> **Harness Layer**: Planning — Let the Agent think before it acts.
-
----
-
-## The Problem
-
-Give the Agent a complex task: "Rename all Python files to snake_case, run tests, and fix failures."
-
-The Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was "rename to snake_case", the test failures have consumed all its attention.
-
-The longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.
-
----
-
-## The Solution
-
-
-
-The minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply lets the Agent organize its thoughts before diving in.
-
-The dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected.
-
----
-
-## How It Works
-
-**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal:
-
-```python
-CURRENT_TODOS: list[dict] = []
-
-def run_todo_write(todos: list) -> str:
- global CURRENT_TODOS
- CURRENT_TODOS = todos
-
- lines = ["\n## Current Tasks"]
- for t in CURRENT_TODOS:
- icon = {"pending": " ", "in_progress": "▸", "completed": "✓"}[t["status"]]
- lines.append(f" [{icon}] {t['content']}")
- print("\n".join(lines))
- return f"Updated {len(CURRENT_TODOS)} tasks"
-```
-
-The tool definition joins the other 5 in the dispatch map:
-
-```python
-TOOLS = [
- {"name": "bash", ...},
- {"name": "read_file", ...},
- {"name": "write_file", ...},
- {"name": "edit_file", ...},
- {"name": "glob", ...},
- # s05: new entry
- {"name": "todo_write", "description": "Create and manage a task list ...",
- "input_schema": {
- "type": "object",
- "properties": {
- "todos": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "content": {"type": "string"},
- "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
- },
- },
- },
- },
- },
- },
-]
-
-TOOL_HANDLERS["todo_write"] = run_todo_write
-```
-
-**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic):
-
-```python
-if rounds_since_todo >= 3 and messages:
- messages.append({
- "role": "user",
- "content": "Update your todos.",
- })
- rounds_since_todo = 0
-```
-
-Typical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call.
-
-**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**.
-
----
-
-## Changes from s04
-
-| Component | Before (s04) | After (s05) |
-|-----------|-------------|-------------|
-| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
-| Planning | None | Stateful TODO list + nag reminder |
-| SYSTEM prompt | Generic prompt | Added "plan before executing" guidance |
-| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s05_todo_write/code.py
-```
-
-Try these prompts:
-
-1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)
-2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
-3. `Review Python files under s05_todo_write/example and fix any style issues`
-
-What to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?
-
----
-
-## What's Next
-
-The Agent can plan now. But if a task is too large, say "refactor the entire auth module", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.
-
-→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.
-
-
-Dive into CC Source Code
-
-CC has two task systems coexisting (`tasks.ts:133-139`):
-
-- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit.
-- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership.
-
-The switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment "Force-enable tasks in non-interactive mode" describes the env var path's purpose, not the default branch's return semantics.
-
-The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field.
-
-The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.
-
-Core increments of the Task System over TodoWrite:
-- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list
-- `blockedBy` dependency graph instead of flat list
-- `proper-lockfile` concurrency safety instead of no locking
-- Four separate tools (Create/Get/Update/List) instead of one
-- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration
-
-
-
-
diff --git a/s05_todo_write/README.ja.md b/s05_todo_write/README.ja.md
index 3830c02d..c242fb48 100644
--- a/s05_todo_write/README.ja.md
+++ b/s05_todo_write/README.ja.md
@@ -1,8 +1,8 @@
# s05: TodoWrite — 計画なき Agent は途中で道を外れる
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
+s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
> *"計画なき agent は風の向くままに"* — まず手順を列挙してから実行。長いタスクで見落としが減る。
>
@@ -135,12 +135,12 @@ Agent は計画できるようになった。しかしタスクが大きすぎ
CC ソースコードを深掘り
-CC には二つのタスクシステムが共存している(`tasks.ts:133-139`):
+Claude Code には、目的は近いがストレージとツール契約が独立した二つの計画機構がある:
-- **TodoWrite(V1)**:シンプルなリストツール、データはメモリ AppState で管理(`TodoWriteTool.ts:65-103`)。教育版もプロセスメモリに保持し、終了時に消える
-- **Task System(V2 = s12)**:ファイル永続化、依存グラフ、並行ロック、ownership
+- **TodoWrite**:現在のセッション向けの軽量チェックリスト。呼び出しごとにリスト全体を置き換え、教育版もプロセスメモリに保持して終了時に消える
+- **Task ツール(s12)**:安定 ID を持つ個別タスクレコードで、依存関係、ownership、永続化を扱う
-切り替えは `isTodoV2Enabled()` で制御される。現在のソースコードの実装:対話型セッションでは V2 がデフォルトで有効、非対話型セッション(SDK)では V1 がデフォルトで有効。`CLAUDE_CODE_ENABLE_TASKS` 環境変数を設定するとセッション種別に関わらず V2 が強制有効になる。ソースコメント「Force-enable tasks in non-interactive mode」は環境変数パスの用途を説明しており、デフォルト分岐の戻り値のセマンティクスとは異なるため注意。
+現在の対話型セッションは構造化 Task ツールを既定で使い、TodoWrite は非対話型や Agent SDK などの互換サーフェスに残る。公開範囲はリリースや設定で変わり得る。同じ schema のインプレース更新ではなく独立した機構であり、s05 は軽量なチェックリスト契約だけを扱う。
教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。CC は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。
diff --git a/s05_todo_write/README.md b/s05_todo_write/README.md
index 3a4de4e1..8d94bc45 100644
--- a/s05_todo_write/README.md
+++ b/s05_todo_write/README.md
@@ -1,38 +1,38 @@
-# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了
+# s05: TodoWrite — An Agent Without a Plan Drifts Off Course
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
+s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
-> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。
+> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps.
>
-> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。
+> **Harness Layer**: Planning — Let the Agent think before it acts.
---
-## 问题
+## The Problem
-给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。"
+Give the Agent a complex task: "Rename all Python files to snake_case, run tests, and fix failures."
-Agent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是"改成 snake_case",测试失败把注意力全吸走了。
+The Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was "rename to snake_case", the test failures have consumed all its attention.
-对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。
+The longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.
---
-## 解决方案
+## The Solution
-
+
-保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是让 Agent 在动手之前先理清思路。
+The minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply lets the Agent organize its thoughts before diving in.
-dispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。
+The dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected.
---
-## 工作原理
+## How It Works
-**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度:
+**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal:
```python
CURRENT_TODOS: list[dict] = []
@@ -49,7 +49,7 @@ def run_todo_write(todos: list) -> str:
return f"Updated {len(CURRENT_TODOS)} tasks"
```
-工具定义和其他 5 个工具一起加入 dispatch map:
+The tool definition joins the other 5 in the dispatch map:
```python
TOOLS = [
@@ -58,7 +58,7 @@ TOOLS = [
{"name": "write_file", ...},
{"name": "edit_file", ...},
{"name": "glob", ...},
- # s05: 新增一条
+ # s05: new entry
{"name": "todo_write", "description": "Create and manage a task list ...",
"input_schema": {
"type": "object",
@@ -81,7 +81,7 @@ TOOLS = [
TOOL_HANDLERS["todo_write"] = run_todo_write
```
-**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑):
+**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic):
```python
if rounds_since_todo >= 3 and messages:
@@ -92,66 +92,66 @@ if rounds_since_todo >= 3 and messages:
rounds_since_todo = 0
```
-Agent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。
+Typical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call.
-**关键洞察**:todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。
+**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**.
---
-## 相对 s04 的变更
+## Changes from s04
-| 组件 | 之前 (s04) | 之后 (s05) |
-|------|-----------|-----------|
-| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
-| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder |
-| SYSTEM 提示 | 通用提示 | 加入 "先计划再执行" 引导 |
-| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 |
+| Component | Before (s04) | After (s05) |
+|-----------|-------------|-------------|
+| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
+| Planning | None | Stateful TODO list + nag reminder |
+| SYSTEM prompt | Generic prompt | Added "plan before executing" guidance |
+| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s05_todo_write/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)
+1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)
2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
3. `Review Python files under s05_todo_write/example and fix any style issues`
-观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`?
+What to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?
---
-## 接下来
+## What's Next
-Agent 能计划了。但如果一个任务太大,比如"重构整个认证模块",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。
+The Agent can plan now. But if a task is too large, say "refactor the entire auth module", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.
-s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。
+→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.
-深入 CC 源码
+Dive into CC Source Code
-CC 中有两套任务系统并存(`tasks.ts:133-139`):
+Claude Code has two planning surfaces with a shared intent but independent storage and tool contracts:
-- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空
-- **Task System(V2 = s12)**:文件持久化、依赖图、并发锁、ownership
+- **TodoWrite**: A session checklist. Each call replaces the whole list, and the teaching version likewise keeps it in process memory and clears it on exit.
+- **Task tools (covered in s12)**: Individually addressable task records with stable IDs, dependency fields, ownership, and persistence.
-切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 "Force-enable tasks in non-interactive mode" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。
+Current interactive sessions use the structured Task tools by default, while TodoWrite remains available on compatibility surfaces such as non-interactive and Agent SDK usage. Exact exposure can vary by release and configuration. Do not model this as one schema being upgraded in place: they are separate mechanisms, and s05 teaches the lighter checklist contract.
-教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。
+The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field.
-教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。
+The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.
-Task System 相比 TodoWrite 的核心增量:
-- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表
-- `blockedBy` 依赖图而非平铺列表
-- `proper-lockfile` 并发安全而非无锁
-- 四个独立工具(Create/Get/Update/List)而非一个
-- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成
+Core increments of the Task System over TodoWrite:
+- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list
+- `blockedBy` dependency graph instead of flat list
+- `proper-lockfile` concurrency safety instead of no locking
+- Four separate tools (Create/Get/Update/List) instead of one
+- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration
diff --git a/s05_todo_write/README.zh.md b/s05_todo_write/README.zh.md
new file mode 100644
index 00000000..60034320
--- /dev/null
+++ b/s05_todo_write/README.zh.md
@@ -0,0 +1,158 @@
+# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
+
+> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。
+>
+> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。
+
+---
+
+## 问题
+
+给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。"
+
+Agent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是"改成 snake_case",测试失败把注意力全吸走了。
+
+对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。
+
+---
+
+## 解决方案
+
+
+
+保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是让 Agent 在动手之前先理清思路。
+
+dispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。
+
+---
+
+## 工作原理
+
+**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度:
+
+```python
+CURRENT_TODOS: list[dict] = []
+
+def run_todo_write(todos: list) -> str:
+ global CURRENT_TODOS
+ CURRENT_TODOS = todos
+
+ lines = ["\n## Current Tasks"]
+ for t in CURRENT_TODOS:
+ icon = {"pending": " ", "in_progress": "▸", "completed": "✓"}[t["status"]]
+ lines.append(f" [{icon}] {t['content']}")
+ print("\n".join(lines))
+ return f"Updated {len(CURRENT_TODOS)} tasks"
+```
+
+工具定义和其他 5 个工具一起加入 dispatch map:
+
+```python
+TOOLS = [
+ {"name": "bash", ...},
+ {"name": "read_file", ...},
+ {"name": "write_file", ...},
+ {"name": "edit_file", ...},
+ {"name": "glob", ...},
+ # s05: 新增一条
+ {"name": "todo_write", "description": "Create and manage a task list ...",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "todos": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "content": {"type": "string"},
+ "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
+ },
+ },
+ },
+ },
+ },
+ },
+]
+
+TOOL_HANDLERS["todo_write"] = run_todo_write
+```
+
+**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑):
+
+```python
+if rounds_since_todo >= 3 and messages:
+ messages.append({
+ "role": "user",
+ "content": "Update your todos.",
+ })
+ rounds_since_todo = 0
+```
+
+Agent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。
+
+**关键洞察**:todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。
+
+---
+
+## 相对 s04 的变更
+
+| 组件 | 之前 (s04) | 之后 (s05) |
+|------|-----------|-----------|
+| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
+| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder |
+| SYSTEM 提示 | 通用提示 | 加入 "先计划再执行" 引导 |
+| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s05_todo_write/code.py
+```
+
+试试这些 prompt:
+
+1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)
+2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
+3. `Review Python files under s05_todo_write/example and fix any style issues`
+
+观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`?
+
+---
+
+## 接下来
+
+Agent 能计划了。但如果一个任务太大,比如"重构整个认证模块",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。
+
+s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。
+
+
+深入 CC 源码
+
+Claude Code 有两种目标相近、但存储与工具契约相互独立的规划机制:
+
+- **TodoWrite**:当前会话的轻量清单。每次调用替换整个列表;教学版同样保存在进程内存,退出后清空
+- **Task 工具(s12)**:带稳定 ID 的独立任务记录,支持依赖、ownership 与持久化
+
+当前交互式会话默认使用结构化 Task 工具;TodoWrite 仍保留在非交互式、Agent SDK 等兼容表面。具体暴露方式会随版本与配置变化。不要把它理解成同一个 schema 原地升级:两者是独立机制,s05 只教授较轻的清单契约。
+
+教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。
+
+教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。
+
+Task System 相比 TodoWrite 的核心增量:
+- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表
+- `blockedBy` 依赖图而非平铺列表
+- `proper-lockfile` 并发安全而非无锁
+- 四个独立工具(Create/Get/Update/List)而非一个
+- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成
+
+
+
+
diff --git a/s06_subagent/README.en.md b/s06_subagent/README.en.md
deleted file mode 100644
index 93313e35..00000000
--- a/s06_subagent/README.en.md
+++ /dev/null
@@ -1,189 +0,0 @@
-# s06: Subagent — Break Large Tasks into Small Ones with Clean Context
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20
-
-> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation.
->
-> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.
-
----
-
-## The Problem
-
-The Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from "tracing the call chain" — unrelated to the final goal of "fixing the bug."
-
-These intermediate steps occupy context space, making the Agent increasingly "forgetful" — it can no longer remember what the original problem was.
-
-Think of it differently: when you fix a bug, you'd "open a new terminal" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs this ability too — **open an independent sub-process, give it an independent message list, let it focus on one thing.**
-
----
-
-## The Solution
-
-
-
-The minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory.
-
-The sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security.
-
----
-
-## How It Works
-
-**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion:
-
-```python
-def spawn_subagent(description: str) -> str:
- # Sub-Agent tools: base tools, but no task (no recursion)
- sub_tools = [...]
- messages = [{"role": "user", "content": description}] # fresh messages[]
-
- for _ in range(30): # safety limit
- response = client.messages.create(
- model=MODEL, system=SUB_SYSTEM,
- messages=messages, tools=sub_tools, max_tokens=8000,
- )
- messages.append({"role": "assistant", "content": response.content})
- if response.stop_reason != "tool_use":
- break
- results = []
- for block in response.content:
- if block.type == "tool_use":
- blocked = trigger_hooks("PreToolUse", block)
- if blocked:
- results.append({... "content": str(blocked)})
- continue
- handler = SUB_HANDLERS.get(block.name)
- output = handler(**block.input) if handler else f"Unknown"
- trigger_hooks("PostToolUse", block, output)
- results.append({... "content": output})
- messages.append({"role": "user", "content": results})
-
- # Return only the final text conclusion, all intermediate steps discarded
- return extract_text(messages[-1]["content"])
-```
-
-The main Agent calls it just like any other tool:
-
-```python
-TOOLS = [
- {"name": "bash", ...},
- {"name": "read_file", ...},
- {"name": "write_file", ...},
- {"name": "edit_file", ...},
- {"name": "glob", ...},
- {"name": "todo_write", ...},
- # s06: new task tool
- {"name": "task",
- "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.",
- "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
-]
-
-TOOL_HANDLERS["task"] = spawn_subagent
-```
-
-Three key design decisions:
-
-| Decision | Choice | Reason |
-|----------|--------|--------|
-| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |
-| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |
-| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |
-| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |
-
-The dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing "complete the task, do not delegate further."
-
----
-
-## Changes from s05
-
-| Component | Before (s05) | After (s06) |
-|-----------|-------------|-------------|
-| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |
-| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |
-| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |
-| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s06_subagent/code.py
-```
-
-Try these prompts:
-
-1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)
-2. `Delegate: read all .py files in agents/ and summarize what each one does`
-3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`
-
-What to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?
-
----
-
-## What's Next
-
-The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.
-
-→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.
-
-
-Dive into CC Source Code
-
-> The following is based on a complete analysis of CC source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.
-
-### 1. Not One Pattern, but Three
-
-The teaching version covers only "fresh messages[]". CC actually has three execution modes:
-
-| Mode | Trigger | Context |
-|------|---------|---------|
-| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |
-| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |
-| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |
-
-### 2. Fork Mode: Sharing Prompt Cache
-
-This is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.
-
-Five key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.
-
-### 3. Context Isolation's Precise Granularity
-
-`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:
-
-| Field | Behavior |
-|-------|----------|
-| `abortController` | New child controller; parent abort propagates down |
-| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |
-| `readFileState` | **Cloned from parent** (avoids re-reading same files) |
-| `queryTracking` | New chainId, `depth = parentDepth + 1` |
-
-The sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).
-
-### 4. Recursive Fork Protection
-
-The teaching version uses "sub-Agent has no task tool" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply "no further sub-Agents."
-
-### 5. Permission Bubbling
-
-Fork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.
-
-### 6. Async vs Sync
-
-The teaching version only shows synchronous sub-Agents (parent waits for child to finish). CC also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.
-
-### Teaching Version Simplifications Are Intentional
-
-- Three modes → one (fresh messages): conceptually clear
-- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization
-- Recursive fork protection → simplified to "sub-Agent has no task tool"
-- Async → omitted (left for s13): s06 focuses on the synchronous model first
-
-
-
-
diff --git a/s06_subagent/README.ja.md b/s06_subagent/README.ja.md
index fc285208..55dc167c 100644
--- a/s06_subagent/README.ja.md
+++ b/s06_subagent/README.ja.md
@@ -1,8 +1,8 @@
# s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20
+s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
> *"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。
>
diff --git a/s06_subagent/README.md b/s06_subagent/README.md
index 6744566b..456727ac 100644
--- a/s06_subagent/README.md
+++ b/s06_subagent/README.md
@@ -1,48 +1,44 @@
-# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文
+# s06: Subagent — Break Large Tasks into Small Ones with Clean Context
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20
+s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
-> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。
+> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation.
>
-> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。
+> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.
---
-## 问题
+## The Problem
-Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是"追踪调用链"的中间过程,和"修 bug"这个最终目标无关。
+The Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from "tracing the call chain" — unrelated to the final goal of "fixing the bug."
-这些中间过程占着上下文位置,让 Agent 越来越"健忘",它记不住最初的问题是什么了。
+These intermediate steps occupy context space, making the Agent increasingly "forgetful" — it can no longer remember what the original problem was.
-换个角度:你修 bug 的时候,会"开一个新终端"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。
+Think of it differently: when you fix a bug, you'd "open a new terminal" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs this ability too — **open an independent sub-process, give it an independent message list, let it focus on one thing.**
---
-## 解决方案
+## The Solution
-
+
-保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。
+The minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory.
-子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。
+The sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security.
---
-## 工作原理
+## How It Works
-**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论:
+**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion:
```python
def spawn_subagent(description: str) -> str:
- # 子 Agent 的工具:基础工具,但没有 task(禁止递归)
- sub_tools = [
- {"name": "bash", ...}, {"name": "read_file", ...},
- {"name": "write_file", ...}, {"name": "edit_file", ...},
- {"name": "glob", ...},
- ]
- messages = [{"role": "user", "content": description}] # 全新 messages[]
+ # Sub-Agent tools: base tools, but no task (no recursion)
+ sub_tools = [...]
+ messages = [{"role": "user", "content": description}] # fresh messages[]
for _ in range(30): # safety limit
response = client.messages.create(
@@ -65,11 +61,11 @@ def spawn_subagent(description: str) -> str:
results.append({... "content": output})
messages.append({"role": "user", "content": results})
- # 只返回最后的文本结论,中间过程全部丢弃
+ # Return only the final text conclusion, all intermediate steps discarded
return extract_text(messages[-1]["content"])
```
-主 Agent 调用时,跟调其他工具一样:
+The main Agent calls it just like any other tool:
```python
TOOLS = [
@@ -79,7 +75,7 @@ TOOLS = [
{"name": "edit_file", ...},
{"name": "glob", ...},
{"name": "todo_write", ...},
- # s06: 新增 task 工具
+ # s06: new task tool
{"name": "task",
"description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.",
"input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
@@ -88,106 +84,106 @@ TOOLS = [
TOOL_HANDLERS["task"] = spawn_subagent
```
-三个关键设计决策:
+Three key design decisions:
-| 决策 | 选择 | 原因 |
-|------|------|------|
-| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |
-| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 |
-| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent |
-| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |
+| Decision | Choice | Reason |
+|----------|--------|--------|
+| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |
+| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |
+| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |
+| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |
-dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。
+The dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing "complete the task, do not delegate further."
---
-## 相对 s05 的变更
+## Changes from s05
-| 组件 | 之前 (s05) | 之后 (s06) |
-|------|-----------|-----------|
-| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |
-| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) |
-| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] |
-| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 |
+| Component | Before (s05) | After (s06) |
+|-----------|-------------|-------------|
+| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |
+| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |
+| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |
+| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s06_subagent/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)
+1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)
2. `Delegate: read all .py files in agents/ and summarize what each one does`
3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`
-观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?
+What to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?
---
-## 接下来
+## What's Next
-Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。
+The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.
-s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。
+→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.
-深入 CC 源码
+Dive into CC Source Code
-> 以下基于 CC 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。
+> The following is based on a complete analysis of CC source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.
-### 一、不是一种模式,是三种
+### 1. Not One Pattern, but Three
-教学版只讲了"全新的 messages[]"。CC 实际有三种执行模式:
+The teaching version covers only "fresh messages[]". CC actually has three execution modes:
-| 模式 | 触发条件 | 上下文 |
-|------|---------|--------|
-| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt |
-| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |
-| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |
+| Mode | Trigger | Context |
+|------|---------|---------|
+| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |
+| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |
+| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |
-### 二、Fork 模式:为了共享 Prompt Cache
+### 2. Fork Mode: Sharing Prompt Cache
-这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。
+This is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.
-缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。
+Five key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.
-### 三、Context Isolation 的精确粒度
+### 3. Context Isolation's Precise Granularity
-`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:
+`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:
-| 字段 | 行为 |
-|------|------|
-| `abortController` | 新的 child controller,父 abort 向下传播 |
-| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |
-| `readFileState` | **从父克隆**(避免重复读相同文件) |
-| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |
+| Field | Behavior |
+|-------|----------|
+| `abortController` | New child controller; parent abort propagates down |
+| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |
+| `readFileState` | **Cloned from parent** (avoids re-reading same files) |
+| `queryTracking` | New chainId, `depth = parentDepth + 1` |
-子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。
+The sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).
-### 四、递归 Fork 防护
+### 4. Recursive Fork Protection
-教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。
+The teaching version uses "sub-Agent has no task tool" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply "no further sub-Agents."
-### 五、Permission Bubbling
+### 5. Permission Bubbling
-Fork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。
+Fork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.
-### 六、Async vs Sync
+### 6. Async vs Sync
-教学版只展示了同步子 Agent(父等着子跑完)。CC 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。
+The teaching version only shows synchronous sub-Agents (parent waits for child to finish). CC also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.
-### 教学版的简化是刻意的
+### Teaching Version Simplifications Are Intentional
-- 三种模式 → 一种(fresh messages):概念清晰
-- Prompt cache 共享 → 省略:教学版不涉及 API 层优化
-- 递归 fork 防护 → 简化为"子 Agent 无 task 工具"
-- Async → 省略(留给 s13):s06 先理解同步模型
+- Three modes → one (fresh messages): conceptually clear
+- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization
+- Recursive fork protection → simplified to "sub-Agent has no task tool"
+- Async → omitted (left for s13): s06 focuses on the synchronous model first
-
+
diff --git a/s06_subagent/README.zh.md b/s06_subagent/README.zh.md
new file mode 100644
index 00000000..a921fff4
--- /dev/null
+++ b/s06_subagent/README.zh.md
@@ -0,0 +1,193 @@
+# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
+
+> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。
+>
+> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。
+
+---
+
+## 问题
+
+Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是"追踪调用链"的中间过程,和"修 bug"这个最终目标无关。
+
+这些中间过程占着上下文位置,让 Agent 越来越"健忘",它记不住最初的问题是什么了。
+
+换个角度:你修 bug 的时候,会"开一个新终端"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。
+
+---
+
+## 解决方案
+
+
+
+保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。
+
+子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。
+
+---
+
+## 工作原理
+
+**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论:
+
+```python
+def spawn_subagent(description: str) -> str:
+ # 子 Agent 的工具:基础工具,但没有 task(禁止递归)
+ sub_tools = [
+ {"name": "bash", ...}, {"name": "read_file", ...},
+ {"name": "write_file", ...}, {"name": "edit_file", ...},
+ {"name": "glob", ...},
+ ]
+ messages = [{"role": "user", "content": description}] # 全新 messages[]
+
+ for _ in range(30): # safety limit
+ response = client.messages.create(
+ model=MODEL, system=SUB_SYSTEM,
+ messages=messages, tools=sub_tools, max_tokens=8000,
+ )
+ messages.append({"role": "assistant", "content": response.content})
+ if response.stop_reason != "tool_use":
+ break
+ results = []
+ for block in response.content:
+ if block.type == "tool_use":
+ blocked = trigger_hooks("PreToolUse", block)
+ if blocked:
+ results.append({... "content": str(blocked)})
+ continue
+ handler = SUB_HANDLERS.get(block.name)
+ output = handler(**block.input) if handler else f"Unknown"
+ trigger_hooks("PostToolUse", block, output)
+ results.append({... "content": output})
+ messages.append({"role": "user", "content": results})
+
+ # 只返回最后的文本结论,中间过程全部丢弃
+ return extract_text(messages[-1]["content"])
+```
+
+主 Agent 调用时,跟调其他工具一样:
+
+```python
+TOOLS = [
+ {"name": "bash", ...},
+ {"name": "read_file", ...},
+ {"name": "write_file", ...},
+ {"name": "edit_file", ...},
+ {"name": "glob", ...},
+ {"name": "todo_write", ...},
+ # s06: 新增 task 工具
+ {"name": "task",
+ "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.",
+ "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
+]
+
+TOOL_HANDLERS["task"] = spawn_subagent
+```
+
+三个关键设计决策:
+
+| 决策 | 选择 | 原因 |
+|------|------|------|
+| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |
+| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 |
+| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent |
+| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |
+
+dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。
+
+---
+
+## 相对 s05 的变更
+
+| 组件 | 之前 (s05) | 之后 (s06) |
+|------|-----------|-----------|
+| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |
+| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) |
+| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] |
+| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s06_subagent/code.py
+```
+
+试试这些 prompt:
+
+1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)
+2. `Delegate: read all .py files in agents/ and summarize what each one does`
+3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`
+
+观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?
+
+---
+
+## 接下来
+
+Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。
+
+s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。
+
+### 一、不是一种模式,是三种
+
+教学版只讲了"全新的 messages[]"。CC 实际有三种执行模式:
+
+| 模式 | 触发条件 | 上下文 |
+|------|---------|--------|
+| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt |
+| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |
+| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |
+
+### 二、Fork 模式:为了共享 Prompt Cache
+
+这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。
+
+缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。
+
+### 三、Context Isolation 的精确粒度
+
+`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:
+
+| 字段 | 行为 |
+|------|------|
+| `abortController` | 新的 child controller,父 abort 向下传播 |
+| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |
+| `readFileState` | **从父克隆**(避免重复读相同文件) |
+| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |
+
+子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。
+
+### 四、递归 Fork 防护
+
+教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。
+
+### 五、Permission Bubbling
+
+Fork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。
+
+### 六、Async vs Sync
+
+教学版只展示了同步子 Agent(父等着子跑完)。CC 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。
+
+### 教学版的简化是刻意的
+
+- 三种模式 → 一种(fresh messages):概念清晰
+- Prompt cache 共享 → 省略:教学版不涉及 API 层优化
+- 递归 fork 防护 → 简化为"子 Agent 无 task 工具"
+- Async → 省略(留给 s13):s06 先理解同步模型
+
+
+
+
diff --git a/s07_skill_loading/README.en.md b/s07_skill_loading/README.en.md
deleted file mode 100644
index 35399ddb..00000000
--- a/s07_skill_loading/README.en.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# s07: Skill Loading — Load Only When Needed
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20
-> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt.
->
-> **Harness Layer**: Knowledge — load on demand, don't fill the context.
-
----
-
-## The Problem
-
-Your project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea — stuff them all into the system prompt:
-
-```python
-SYSTEM = (
- f"You are a coding agent. "
- + open("docs/react-style.md").read() # 2000 lines
- + open("docs/sql-style.md").read() # 1500 lines
- + open("docs/api-design.md").read() # 3000 lines
-)
-```
-
-6500 lines of system prompt. The Agent carries these docs on every LLM call — whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.
-
----
-
-## The Solution
-
-
-
-The minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used.
-
-Two-level design:
-
-| Level | Location | Timing | Cost |
-|-------|----------|--------|------|
-| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |
-| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |
-
-The dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.
-
----
-
-## How It Works
-
-**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:
-
-```
-skills/
- agent-builder/SKILL.md
- code-review/SKILL.md
- mcp-builder/SKILL.md
- pdf/SKILL.md
-```
-
-**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees "which skills I have available" every turn, with no extra API calls:
-
-```python
-SKILL_REGISTRY: dict[str, dict] = {}
-
-def _scan_skills():
- if not SKILLS_DIR.exists():
- return
- for d in sorted(SKILLS_DIR.iterdir()):
- if not d.is_dir():
- continue
- manifest = d / "SKILL.md"
- if manifest.exists():
- raw = manifest.read_text()
- meta, body = _parse_frontmatter(raw)
- name = meta.get("name", d.name)
- desc = meta.get("description", raw.split("\n")[0].lstrip("#").strip())
- SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": raw}
-
-_scan_skills() # runs once at startup
-
-def list_skills() -> str:
- return "\n".join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())
-
-def build_system() -> str:
- catalog = list_skills()
- return (
- f"You are a coding agent at {WORKDIR}. "
- f"Skills available:\n{catalog}\n"
- "Use load_skill to get full details when needed."
- )
-
-SYSTEM = build_system()
-```
-
-**Level 2: load_skill**: the Agent decides "I need the SQL style guide" and calls `load_skill("sql-style")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.
-
-```python
-def load_skill(name: str) -> str:
- skill = SKILL_REGISTRY.get(name)
- if not skill:
- return f"Skill not found: {name}"
- return skill["content"]
-```
-
-The key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading solves "don't carry what you shouldn't", compact solves "how to drop what you should."
-
----
-
-## Changes from s06
-
-| Component | Before (s06) | After (s07) |
-|-----------|-------------|-------------|
-| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |
-| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |
-| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |
-| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |
-| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s07_skill_loading/code.py
-```
-
-Try these prompts:
-
-1. `What skills are available?`
-2. `Load the code-review skill and follow its instructions`
-3. `I need to do a code review -- load the relevant skill first`
-
-What to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?
-
----
-
-## What's Next
-
-On-demand loading solved "don't carry what you shouldn't." But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value.
-
-→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.
-
-
-Dive into CC Source Code
-
-> The following is based on analysis of CC source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.
-
-### 1. Skill Sources: Not Just One skills/ Directory
-
-The teaching version assumes all skills live in a `skills/` directory. CC loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.
-
-### 2. SKILL.md Frontmatter — Common Fields
-
-CC's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:
-
-| Field | Purpose |
-|-------|---------|
-| `name` / `description` | Display name and description |
-| `when_to_use` | Guides the model on when to invoke |
-| `allowed-tools` | Auto-allow list of tools available to the skill |
-| `context` | `inline` (default) or `fork` (run as sub-Agent) |
-| `model` | Model override (haiku/sonnet/opus/inherit) |
-| `hooks` | Skill-level hook configuration |
-| `paths` | Glob patterns for conditional activation |
-| `user-invocable` | Users can invoke via `/name` |
-
-The complete field list changes across versions; above are the core fields relevant to the teaching version.
-
-### 3. Precise Implementation of Two-Level Loading
-
-1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).
-2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `"Launching skill: {name}"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into "injected via tool_result" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.
-
-### The Teaching Version's Simplification Is Intentional
-
-- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading
-- Multiple frontmatter fields → only parse name/description: reduces parsing complexity
-- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading
-- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity
-
-
-
-
diff --git a/s07_skill_loading/README.ja.md b/s07_skill_loading/README.ja.md
index 7e12baa8..d458137e 100644
--- a/s07_skill_loading/README.ja.md
+++ b/s07_skill_loading/README.ja.md
@@ -1,8 +1,8 @@
# s07: Skill Loading — 必要なときにだけ読み込む
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20
+s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
> *"Load when needed, don't stuff the prompt"* — tool_result で注入、system prompt には詰め込まない。
>
> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。
diff --git a/s07_skill_loading/README.md b/s07_skill_loading/README.md
index 8b53c764..a5c09d13 100644
--- a/s07_skill_loading/README.md
+++ b/s07_skill_loading/README.md
@@ -1,51 +1,51 @@
-# s07: Skill Loading — 用到的时候才加载
+# s07: Skill Loading — Load Only When Needed
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20
-> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。
+s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
+> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt.
>
-> **Harness 层**: 知识 — 按需加载, 不堆满上下文。
+> **Harness Layer**: Knowledge — load on demand, don't fill the context.
---
-## 问题
+## The Problem
-你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:
+Your project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea — stuff them all into the system prompt:
```python
SYSTEM = (
f"You are a coding agent. "
- + open("docs/react-style.md").read() # 2000 行
- + open("docs/sql-style.md").read() # 1500 行
- + open("docs/api-design.md").read() # 3000 行
+ + open("docs/react-style.md").read() # 2000 lines
+ + open("docs/sql-style.md").read() # 1500 lines
+ + open("docs/api-design.md").read() # 3000 lines
)
```
-6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。
+6500 lines of system prompt. The Agent carries these docs on every LLM call — whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.
---
-## 解决方案
+## The Solution
-
+
-保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。
+The minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used.
-两层设计:
+Two-level design:
-| 层 | 位置 | 时机 | 代价 |
-|---|------|------|------|
-| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |
-| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |
+| Level | Location | Timing | Cost |
+|-------|----------|--------|------|
+| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |
+| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |
-dispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。
+The dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.
---
-## 工作原理
+## How It Works
-**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:
+**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:
```
skills/
@@ -55,7 +55,7 @@ skills/
pdf/SKILL.md
```
-**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到"我有哪些技能可用",不花额外 API 调用:
+**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees "which skills I have available" every turn, with no extra API calls:
```python
SKILL_REGISTRY: dict[str, dict] = {}
@@ -90,7 +90,7 @@ def build_system() -> str:
SYSTEM = build_system()
```
-**第二级:load_skill**:Agent 决定"我需要 SQL 风格指南",调用 `load_skill("sql-style")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。
+**Level 2: load_skill**: the Agent decides "I need the SQL style guide" and calls `load_skill("sql-style")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.
```python
def load_skill(name: str) -> str:
@@ -100,82 +100,82 @@ def load_skill(name: str) -> str:
return skill["content"]
```
-关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了"不该提前带的不要带",compact 解决"该丢的怎么丢"。
+The key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading solves "don't carry what you shouldn't", compact solves "how to drop what you should."
---
-## 相对 s06 的变更
+## Changes from s06
-| 组件 | 之前 (s06) | 之后 (s07) |
-|------|-----------|-----------|
-| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |
-| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |
-| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |
-| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |
-| 循环 | 不变 | 不变(skill 工具自动分发) |
+| Component | Before (s06) | After (s07) |
+|-----------|-------------|-------------|
+| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |
+| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |
+| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |
+| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |
+| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s07_skill_loading/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `What skills are available?`
2. `Load the code-review skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
-观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?
+What to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?
---
-## 接下来
+## What's Next
-按需加载解决了"不该带的不要带"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。
+On-demand loading solved "don't carry what you shouldn't." But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value.
-s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。
+→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.
-深入 CC 源码
+Dive into CC Source Code
-> 以下基于 CC 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。
+> The following is based on analysis of CC source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.
-### 一、技能来源:不是只有一个 skills/ 目录
+### 1. Skill Sources: Not Just One skills/ Directory
-教学版假设所有技能在 `skills/` 目录下。CC 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。
+The teaching version assumes all skills live in a `skills/` directory. CC loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.
-### 二、SKILL.md Frontmatter 常见字段
+### 2. SKILL.md Frontmatter — Common Fields
-CC 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:
+CC's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:
-| 字段 | 用途 |
-|------|------|
-| `name` / `description` | 显示名称和描述 |
-| `when_to_use` | 指导模型何时调用 |
-| `allowed-tools` | 技能可用工具的自动允许列表 |
-| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |
-| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |
-| `hooks` | 技能级别的 hook 配置 |
-| `paths` | 条件激活的 glob 模式 |
-| `user-invocable` | 用户可以通过 `/name` 调用 |
+| Field | Purpose |
+|-------|---------|
+| `name` / `description` | Display name and description |
+| `when_to_use` | Guides the model on when to invoke |
+| `allowed-tools` | Auto-allow list of tools available to the skill |
+| `context` | `inline` (default) or `fork` (run as sub-Agent) |
+| `model` | Model override (haiku/sonnet/opus/inherit) |
+| `hooks` | Skill-level hook configuration |
+| `paths` | Glob patterns for conditional activation |
+| `user-invocable` | Users can invoke via `/name` |
-完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。
+The complete field list changes across versions; above are the core fields relevant to the teaching version.
-### 三、两级加载的精确实现
+### 3. Precise Implementation of Two-Level Loading
-1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。
-2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。
+1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).
+2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `"Launching skill: {name}"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into "injected via tool_result" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.
-### 教学版的简化是刻意的
+### The Teaching Version's Simplification Is Intentional
-- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念
-- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度
-- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载
-- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度
+- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading
+- Multiple frontmatter fields → only parse name/description: reduces parsing complexity
+- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading
+- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity
diff --git a/s07_skill_loading/README.zh.md b/s07_skill_loading/README.zh.md
new file mode 100644
index 00000000..abb94546
--- /dev/null
+++ b/s07_skill_loading/README.zh.md
@@ -0,0 +1,182 @@
+# s07: Skill Loading — 用到的时候才加载
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
+> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。
+>
+> **Harness 层**: 知识 — 按需加载, 不堆满上下文。
+
+---
+
+## 问题
+
+你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:
+
+```python
+SYSTEM = (
+ f"You are a coding agent. "
+ + open("docs/react-style.md").read() # 2000 行
+ + open("docs/sql-style.md").read() # 1500 行
+ + open("docs/api-design.md").read() # 3000 行
+)
+```
+
+6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。
+
+---
+
+## 解决方案
+
+
+
+保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。
+
+两层设计:
+
+| 层 | 位置 | 时机 | 代价 |
+|---|------|------|------|
+| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |
+| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |
+
+dispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。
+
+---
+
+## 工作原理
+
+**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:
+
+```
+skills/
+ agent-builder/SKILL.md
+ code-review/SKILL.md
+ mcp-builder/SKILL.md
+ pdf/SKILL.md
+```
+
+**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到"我有哪些技能可用",不花额外 API 调用:
+
+```python
+SKILL_REGISTRY: dict[str, dict] = {}
+
+def _scan_skills():
+ if not SKILLS_DIR.exists():
+ return
+ for d in sorted(SKILLS_DIR.iterdir()):
+ if not d.is_dir():
+ continue
+ manifest = d / "SKILL.md"
+ if manifest.exists():
+ raw = manifest.read_text()
+ meta, body = _parse_frontmatter(raw)
+ name = meta.get("name", d.name)
+ desc = meta.get("description", raw.split("\n")[0].lstrip("#").strip())
+ SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": raw}
+
+_scan_skills() # runs once at startup
+
+def list_skills() -> str:
+ return "\n".join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())
+
+def build_system() -> str:
+ catalog = list_skills()
+ return (
+ f"You are a coding agent at {WORKDIR}. "
+ f"Skills available:\n{catalog}\n"
+ "Use load_skill to get full details when needed."
+ )
+
+SYSTEM = build_system()
+```
+
+**第二级:load_skill**:Agent 决定"我需要 SQL 风格指南",调用 `load_skill("sql-style")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。
+
+```python
+def load_skill(name: str) -> str:
+ skill = SKILL_REGISTRY.get(name)
+ if not skill:
+ return f"Skill not found: {name}"
+ return skill["content"]
+```
+
+关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了"不该提前带的不要带",compact 解决"该丢的怎么丢"。
+
+---
+
+## 相对 s06 的变更
+
+| 组件 | 之前 (s06) | 之后 (s07) |
+|------|-----------|-----------|
+| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |
+| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |
+| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |
+| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |
+| 循环 | 不变 | 不变(skill 工具自动分发) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s07_skill_loading/code.py
+```
+
+试试这些 prompt:
+
+1. `What skills are available?`
+2. `Load the code-review skill and follow its instructions`
+3. `I need to do a code review -- load the relevant skill first`
+
+观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?
+
+---
+
+## 接下来
+
+按需加载解决了"不该带的不要带"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。
+
+s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。
+
+### 一、技能来源:不是只有一个 skills/ 目录
+
+教学版假设所有技能在 `skills/` 目录下。CC 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。
+
+### 二、SKILL.md Frontmatter 常见字段
+
+CC 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:
+
+| 字段 | 用途 |
+|------|------|
+| `name` / `description` | 显示名称和描述 |
+| `when_to_use` | 指导模型何时调用 |
+| `allowed-tools` | 技能可用工具的自动允许列表 |
+| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |
+| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |
+| `hooks` | 技能级别的 hook 配置 |
+| `paths` | 条件激活的 glob 模式 |
+| `user-invocable` | 用户可以通过 `/name` 调用 |
+
+完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。
+
+### 三、两级加载的精确实现
+
+1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。
+2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。
+
+### 教学版的简化是刻意的
+
+- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念
+- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度
+- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载
+- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度
+
+
+
+
diff --git a/s08_context_compact/README.en.md b/s08_context_compact/README.en.md
deleted file mode 100644
index b35b7125..00000000
--- a/s08_context_compact/README.en.md
+++ /dev/null
@@ -1,310 +0,0 @@
-# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20
-> *"Context will fill up — have a way to make room"* — Four-layer compression pipeline: cheap first, expensive last.
->
-> **Harness Layer**: Compression — clean memory, unlimited sessions.
-
----
-
-## The Problem
-
-The agent is running along, then freezes.
-
-It has bash, read, write — all the capabilities it needs. But it read a 1000-line file (~4000 tokens), then read 30 more files, ran 20 commands. Every command's output, every file's contents, all pile up in the `messages` list.
-
-The context window is finite. Once full, the API outright rejects the call: `prompt_too_long`.
-
-Without compression, an agent simply cannot work on large projects.
-
----
-
-## The Solution
-
-
-
-The hook structure, skill loading, and sub-Agent from s07 are preserved, with some tools omitted to focus on compaction. The core change: insert three pre-processors (0 API calls) before each LLM call, trigger an LLM summary (1 API call) when tokens still exceed the threshold, and emergency-trim if the API throws an error.
-
-Core design: cheap first, expensive last.
-
----
-
-## How It Works
-
-
-
-### L1: snip_compact — Trim Irrelevant Old Conversation
-
-The agent ran 80 turns of conversation, accumulating 160 `messages`. The very first "help me create hello.py" is barely relevant to current work, yet it still occupies space.
-
-Message count exceeds 50 → keep the first 3 (initial context) and the last 47 (current work), trim the middle; the only extra boundary rule is that `assistant(tool_use)` must not be separated from the following `user(tool_result)`:
-
-```python
-def snip_compact(messages, max_messages=50):
- if len(messages) <= max_messages:
- return messages
- head_end, tail_start = 3, len(messages) - (max_messages - 3)
- if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):
- while head_end < len(messages) and _is_tool_result_message(messages[head_end]):
- head_end += 1
- if (tail_start > 0 and tail_start < len(messages)
- and _is_tool_result_message(messages[tail_start])
- and _message_has_tool_use(messages[tail_start - 1])):
- tail_start -= 1
- snipped = tail_start - head_end
- placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"}
- return messages[:head_end] + [placeholder] + messages[tail_start:]
-```
-
-Messages are still trimmed directly; this just adds one boundary guard. `tool_result` content within remaining messages still keeps accumulating — message #34 may still hold 30KB of old file contents. → L2.
-
-### L2: micro_compact — Placeholder for Old Tool Results
-
-
-
-The agent read 10 files consecutively. The full contents of reads 1–7 are still sitting in context, no longer needed, but hogging large amounts of space.
-
-Keep only the 3 most recent `tool_result` entries intact; replace older ones with a one-line placeholder:
-
-```python
-KEEP_RECENT_TOOL_RESULTS = 3
-
-def micro_compact(messages):
- tool_results = collect_tool_result_blocks(messages)
- if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
- return messages
- for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
- if len(block.get("content", "")) > 120:
- block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
- return messages
-```
-
-Old results are cleared, but a single new result can be 500KB — one `cat` of a large file can max out the context. → L3.
-
-### L3: tool_result_budget — Persist Large Results to Disk
-
-
-
-The model read 5 large files in one go; all `tool_result` blocks in the last user message total 500KB.
-
-Sum the size of all `tool_result` blocks in the last user message. If over 200KB → sort by size, starting from the largest, persist to `.task_outputs/tool-results/`, keeping only a `` marker + a 2000-character preview in context. The model sees the marker and knows the full content is on disk, re-reading it when needed.
-
-```python
-def tool_result_budget(messages, max_bytes=200_000):
- last = messages[-1]
- blocks = [(i, b) for i, b in enumerate(last["content"])
- if b.get("type") == "tool_result"]
- total = sum(len(str(b.get("content", ""))) for _, b in blocks)
- if total <= max_bytes:
- return messages
- ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True)
- for idx, block in ranked:
- if total <= max_bytes:
- break
- block["content"] = persist_large_output(block["tool_use_id"], str(block["content"]))
- total = recalculate_total(blocks)
- return messages
-```
-
-The first three layers are all plain-text / structural operations — 0 API calls — but they cannot "understand" conversation content. Context may still be too large. → L4.
-
-### L4: compact_history — Full LLM Summary
-
-
-
-All three previous layers have run, but after 30 minutes of continuous work on a huge project, tokens still exceed the threshold.
-
-Three-step process:
-
-1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript preserves a recoverable record, but the model's active context only contains the summary. For the model's current reasoning, the details are no longer in context. The teaching code does not provide a transcript retrieval tool.
-2. **LLM generates summary**: Send conversation history to the LLM, asking it to preserve key information: current goals, important findings, modified files, remaining work, user constraints, etc.
-3. **Replace message list**: All old messages are replaced with a single summary. The teaching version only keeps the summary; the real Claude Code re-attaches some recent files, plans, agent/skill/tool context after compaction.
-
-```python
-def compact_history(messages):
- transcript_path = write_transcript(messages) # Save full conversation first
- summary = summarize_history(messages) # LLM generates summary
- return [{"role": "user",
- "content": f"[Compacted]\n\n{summary}"}]
-```
-
-**Circuit breaker**: After 3 consecutive failures, stop retrying to prevent an infinite loop wasting API calls.
-
-### Reactive: reactive_compact
-
-Sometimes the API still returns `prompt_too_long` (413) — when context grows faster than compression triggers.
-
-This triggers **reactive_compact**: more aggressive than compact_history in trigger (emergency response to a 413 error), but more conservative in what it removes, keeping ~5 recent messages and only summarizing earlier history. Still avoids an orphaned `tool_result`.
-
-```python
-def reactive_compact(messages):
- transcript = write_transcript(messages)
- tail_start = max(0, len(messages) - 5)
- if (tail_start > 0 and tail_start < len(messages)
- and _is_tool_result_message(messages[tail_start])
- and _message_has_tool_use(messages[tail_start - 1])):
- tail_start -= 1
- summary = summarize_history(messages[:tail_start])
- return [{"role": "user",
- "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]]
-```
-
-Reactive compact has a retry limit (default 1). If it still fails, an exception is raised instead of looping forever. Full error recovery is deferred to s11.
-
-### Putting It All Together
-
-```python
-def agent_loop(messages):
- reactive_retries = 0
- while True:
- # Three pre-processors (0 API calls)
- # Order: budget first, so large content is persisted before placeholders
- messages[:] = tool_result_budget(messages) # L3: persist large results
- messages[:] = snip_compact(messages) # L1: trim middle
- messages[:] = micro_compact(messages) # L2: old result placeholders
-
- # Still too much? LLM summary (1 API call)
- if estimate_token_count(messages) > THRESHOLD:
- messages[:] = compact_history(messages)
-
- try:
- response = client.messages.create(...)
- except PromptTooLongError:
- if reactive_retries < MAX_REACTIVE_RETRIES:
- messages[:] = reactive_compact(messages) # Emergency
- reactive_retries += 1
- continue
- raise # retry limit exceeded, raise exception
- # ... tool execution ...
-
- # compact tool: when the model actively calls it, triggers compact_history
- if block.name == "compact":
- messages[:] = compact_history(messages)
- results.append({..., "content": "[Compacted. History summarized.]"})
- messages.append({"role": "user", "content": results})
- break # end current turn, start fresh with compacted context
-```
-
-**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders — budget must persist the full content before that happens. This is why CC source puts `applyToolResultBudget` first.
-
----
-
-## Changes From s07
-
-| Component | Before (s07) | After (s08) |
-|-----------|-------------|-------------|
-| Context management | None (context grows unbounded) | Four-layer compression pipeline + emergency |
-| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |
-| Tools | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) |
-| Loop | LLM call → tool execution | Three pre-processors before each turn + threshold-triggered compact_history |
-| Design principle | — | Cheap first, expensive last |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s08_context_compact/code.py
-```
-
-Try these prompts:
-
-1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read multiple files consecutively, observe L2 compressing old results)
-2. `Read every file in s08_context_compact/` (read a large amount of content at once, observe L3 persisting to disk)
-3. Chat for 20+ turns, observe whether `[auto compact]` or `[reactive compact]` appears
-
-What to watch for: After each tool execution, are old `tool_result` entries compressed? When tokens exceed the threshold after extended conversation, is summarization triggered automatically?
-
----
-
-## What's Next
-
-Context compression lets an agent run for a long time without crashing. But after each compression, the preferences and constraints the user told it are also lost. Can we let the agent selectively remember important things?
-
-s09 Memory → three subsystems: choosing what to remember, extracting key information, consolidating and organizing. Across compressions, across sessions.
-
-
-Deep Dive Into CC Source Code
-
-> The following is based on analysis of CC source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.
-
-### Execution Order Comparison
-
-The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:
-
-| Dimension | Teaching Version | Claude Code |
-|-----------|-----------------|-------------|
-| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |
-| snip_compact | Keep head 3 + tail 47 | CC only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |
-| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |
-| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |
-| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |
-| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |
-| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags |
-| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |
-| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |
-| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |
-| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |
-| Reactive retry | 1 time | CC has more granular tiered retries |
-
-### Execution Order Details
-
-The real order in CC source `query.ts`:
-
-1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved
-2. `snipCompact` (L403): trim middle messages
-3. `microcompact` (L414): old result placeholders
-4. `contextCollapse` (L441): independent context management system (not in teaching version)
-5. `autoCompact` (L454): LLM full summary
-
-The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.
-
-### read_file Trade-off
-
-The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates.
-
-Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed.
-
-### Full Constant Reference
-
-| Constant | Value | Source File |
-|----------|-------|-------------|
-| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
-| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
-| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
-| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
-| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
-| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
-| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |
-| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
-
-### contextCollapse and sessionMemoryCompact
-
-CC source code has two additional mechanisms not covered in this teaching version:
-
-- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.
-- **sessionMemoryCompact**: Before compact_history, CC first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.
-
-### What Does the Compression Prompt Look Like?
-
-CC's compression prompt has two hard requirements:
-
-1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end
-2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting
-
-### Teaching Version Simplifications Are Intentional
-
-- micro_compact uses text placeholders → we don't have API-level `cache_edits` access
-- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery
-- Tokens estimated via character count → precise tokenizers are out of scope
-- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files
-- Two auxiliary mechanisms not covered → they fall in the 10% detail category
-
-The core design principle, cheap first, expensive last, is fully preserved.
-
-
-
-
diff --git a/s08_context_compact/README.ja.md b/s08_context_compact/README.ja.md
index 9d8ba810..967039c1 100644
--- a/s08_context_compact/README.ja.md
+++ b/s08_context_compact/README.ja.md
@@ -1,8 +1,8 @@
# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20
+s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
> *"Context will fill up — have a way to make room"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。
>
> **Harness レイヤー**: 圧縮 — クリーンな記憶、無限のセッション。
@@ -29,6 +29,8 @@ s07 のフック構造、スキルロード、サブ Agent の骨格を維持し
コア設計:安価なものを先に、高価なものを後に。
+> **s09 との境界:** s08 は現在のセッションの有限なコンテキストを管理し、圧縮では詳細を失うことがある。s09 は圧縮後や将来のセッションにも残す情報だけを別の永続ストアに保存する。異なる障害を解くため、別のセッションとして扱う。
+
---
## 仕組み
diff --git a/s08_context_compact/README.md b/s08_context_compact/README.md
index 7f63b49f..b6ba6e2b 100644
--- a/s08_context_compact/README.md
+++ b/s08_context_compact/README.md
@@ -1,45 +1,47 @@
-# s08: Context Compact — 上下文总会满,要有办法腾地方
+# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20
-> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。
+s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
+> *"Context will fill up — have a way to make room"* — Four-layer compression pipeline: cheap first, expensive last.
>
-> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。
+> **Harness Layer**: Compression — clean memory, unlimited sessions.
---
-## 问题
+## The Problem
-Agent 跑着跑着,不动了。
+The agent is running along, then freezes.
-手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。
+It has bash, read, write — all the capabilities it needs. But it read a 1000-line file (~4000 tokens), then read 30 more files, ran 20 commands. Every command's output, every file's contents, all pile up in the `messages` list.
-上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。
+The context window is finite. Once full, the API outright rejects the call: `prompt_too_long`.
-不压缩,Agent 根本没法在大项目里干活。
+Without compression, an agent simply cannot work on large projects.
---
-## 解决方案
+## The Solution
-
+
-保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。
+The hook structure, skill loading, and sub-Agent from s07 are preserved, with some tools omitted to focus on compaction. The core change: insert three pre-processors (0 API calls) before each LLM call, trigger an LLM summary (1 API call) when tokens still exceed the threshold, and emergency-trim if the API throws an error.
-核心设计:便宜的先跑,贵的后跑。
+Core design: cheap first, expensive last.
+
+> **Boundary with s09:** s08 manages the current session's finite context and may lose detail while compressing it. s09 adds a separate durable store for selected information that must survive compaction and future sessions. They solve different failure modes, so they remain separate lessons.
---
-## 工作原理
+## How It Works
-
+
-### L1: snip_compact — 裁掉无关的旧对话
+### L1: snip_compact — Trim Irrelevant Old Conversation
-Agent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的"帮我创建 hello.py"和当前工作几乎无关了,但全占着位置。
+The agent ran 80 turns of conversation, accumulating 160 `messages`. The very first "help me create hello.py" is barely relevant to current work, yet it still occupies space.
-消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开:
+Message count exceeds 50 → keep the first 3 (initial context) and the last 47 (current work), trim the middle; the only extra boundary rule is that `assistant(tool_use)` must not be separated from the following `user(tool_result)`:
```python
def snip_compact(messages, max_messages=50):
@@ -58,15 +60,15 @@ def snip_compact(messages, max_messages=50):
return messages[:head_end] + [placeholder] + messages[tail_start:]
```
-裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。
+Messages are still trimmed directly; this just adds one boundary guard. `tool_result` content within remaining messages still keeps accumulating — message #34 may still hold 30KB of old file contents. → L2.
-### L2: micro_compact — 旧工具结果占位
+### L2: micro_compact — Placeholder for Old Tool Results
-
+
-Agent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。
+The agent read 10 files consecutively. The full contents of reads 1–7 are still sitting in context, no longer needed, but hogging large amounts of space.
-只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符:
+Keep only the 3 most recent `tool_result` entries intact; replace older ones with a one-line placeholder:
```python
KEEP_RECENT_TOOL_RESULTS = 3
@@ -81,15 +83,15 @@ def micro_compact(messages):
return messages
```
-旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。
+Old results are cleared, but a single new result can be 500KB — one `cat` of a large file can max out the context. → L3.
-### L3: tool_result_budget — 大结果落盘
+### L3: tool_result_budget — Persist Large Results to Disk
-
+
-模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。
+The model read 5 large files in one go; all `tool_result` blocks in the last user message total 500KB.
-统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。
+Sum the size of all `tool_result` blocks in the last user message. If over 200KB → sort by size, starting from the largest, persist to `.task_outputs/tool-results/`, keeping only a `` marker + a 2000-character preview in context. The model sees the marker and knows the full content is on disk, re-reading it when needed.
```python
def tool_result_budget(messages, max_bytes=200_000):
@@ -108,35 +110,35 @@ def tool_result_budget(messages, max_bytes=200_000):
return messages
```
-前三层都是纯文本/结构操作,0 API 调用,但也无法"理解"对话内容。上下文可能仍然太大。→ L4。
+The first three layers are all plain-text / structural operations — 0 API calls — but they cannot "understand" conversation content. Context may still be too large. → L4.
-### L4: compact_history — LLM 全量摘要
+### L4: compact_history — Full LLM Summary
-
+
-前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。
+All three previous layers have run, but after 30 minutes of continuous work on a huge project, tokens still exceed the threshold.
-三步流程:
+Three-step process:
-1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留了可恢复记录,但模型的活跃上下文里只剩摘要。对模型当下推理来说,细节已经不在上下文中了。教学代码没有提供 transcript 检索工具。
-2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。
-3. **替换消息列表**:所有旧消息被替换为一条摘要。教学版只保留摘要;真实 Claude Code 会在 compact 后重新附加部分最近文件、计划、agent/skill/tool 等上下文。
+1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript preserves a recoverable record, but the model's active context only contains the summary. For the model's current reasoning, the details are no longer in context. The teaching code does not provide a transcript retrieval tool.
+2. **LLM generates summary**: Send conversation history to the LLM, asking it to preserve key information: current goals, important findings, modified files, remaining work, user constraints, etc.
+3. **Replace message list**: All old messages are replaced with a single summary. The teaching version only keeps the summary; the real Claude Code re-attaches some recent files, plans, agent/skill/tool context after compaction.
```python
def compact_history(messages):
- transcript_path = write_transcript(messages) # 先保存完整对话
- summary = summarize_history(messages) # LLM 生成摘要
+ transcript_path = write_transcript(messages) # Save full conversation first
+ summary = summarize_history(messages) # LLM generates summary
return [{"role": "user",
"content": f"[Compacted]\n\n{summary}"}]
```
-**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。
+**Circuit breaker**: After 3 consecutive failures, stop retrying to prevent an infinite loop wasting API calls.
-### 应急: reactive_compact
+### Reactive: reactive_compact
-有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。
+Sometimes the API still returns `prompt_too_long` (413) — when context grows faster than compression triggers.
-这时触发 **reactive_compact**:触发方式比 compact_history 更激进(API 报错后的应急手段),但压缩策略更温和,保留最近约 5 条原始消息,只总结较早历史。同样避免留下孤立 `tool_result`。
+This triggers **reactive_compact**: more aggressive than compact_history in trigger (emergency response to a 413 error), but more conservative in what it removes, keeping ~5 recent messages and only summarizing earlier history. Still avoids an orphaned `tool_result`.
```python
def reactive_compact(messages):
@@ -151,21 +153,21 @@ def reactive_compact(messages):
"content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]]
```
-reactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。
+Reactive compact has a retry limit (default 1). If it still fails, an exception is raised instead of looping forever. Full error recovery is deferred to s11.
-### 合起来跑
+### Putting It All Together
```python
def agent_loop(messages):
reactive_retries = 0
while True:
- # 三个预处理器(0 API 调用)
- # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪
- messages[:] = tool_result_budget(messages) # L3: 大结果落盘
- messages[:] = snip_compact(messages) # L1: 裁中间
- messages[:] = micro_compact(messages) # L2: 旧结果占位
+ # Three pre-processors (0 API calls)
+ # Order: budget first, so large content is persisted before placeholders
+ messages[:] = tool_result_budget(messages) # L3: persist large results
+ messages[:] = snip_compact(messages) # L1: trim middle
+ messages[:] = micro_compact(messages) # L2: old result placeholders
- # 还不够?LLM 摘要(1 API 调用)
+ # Still too much? LLM summary (1 API call)
if estimate_token_count(messages) > THRESHOLD:
messages[:] = compact_history(messages)
@@ -173,137 +175,137 @@ def agent_loop(messages):
response = client.messages.create(...)
except PromptTooLongError:
if reactive_retries < MAX_REACTIVE_RETRIES:
- messages[:] = reactive_compact(messages) # 应急
+ messages[:] = reactive_compact(messages) # Emergency
reactive_retries += 1
continue
- raise # 超过重试上限,抛出异常
- # ... 工具执行 ...
+ raise # retry limit exceeded, raise exception
+ # ... tool execution ...
- # compact 工具:模型主动调用时触发 compact_history
+ # compact tool: when the model actively calls it, triggers compact_history
if block.name == "compact":
messages[:] = compact_history(messages)
results.append({..., "content": "[Compacted. History summarized.]"})
messages.append({"role": "user", "content": results})
- break # 结束当前 turn,用压缩后的上下文开始新一轮
+ break # end current turn, start fresh with compacted context
```
-**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 tool_result 替换成一行占位符,budget 必须在那之前把完整内容落盘。这也是为什么 CC 源码把 `applyToolResultBudget` 放在最前面。
+**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders — budget must persist the full content before that happens. This is why CC source puts `applyToolResultBudget` first.
---
-## 相对 s07 的变更
+## Changes From s07
-| 组件 | 之前 (s07) | 之后 (s08) |
-|------|-----------|-----------|
-| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |
-| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |
-| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |
-| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |
-| 设计原则 | — | 便宜的先跑,贵的后跑 |
+| Component | Before (s07) | After (s08) |
+|-----------|-------------|-------------|
+| Context management | None (context grows unbounded) | Four-layer compression pipeline + emergency |
+| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |
+| Tools | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) |
+| Loop | LLM call → tool execution | Three pre-processors before each turn + threshold-triggered compact_history |
+| Design principle | — | Cheap first, expensive last |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s08_context_compact/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)
-2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)
-3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`
+1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read multiple files consecutively, observe L2 compressing old results)
+2. `Read every file in s08_context_compact/` (read a large amount of content at once, observe L3 persisting to disk)
+3. Chat for 20+ turns, observe whether `[auto compact]` or `[reactive compact]` appears
-观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?
+What to watch for: After each tool execution, are old `tool_result` entries compressed? When tokens exceed the threshold after extended conversation, is summarization triggered automatically?
---
-## 接下来
+## What's Next
-上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事?
+Context compression lets an agent run for a long time without crashing. But after each compression, the preferences and constraints the user told it are also lost. Can we let the agent selectively remember important things?
-s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。
+s09 Memory → three subsystems: choosing what to remember, extracting key information, consolidating and organizing. Across compressions, across sessions.
-深入 CC 源码
+Deep Dive Into CC Source Code
-> 以下基于 CC 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。
+> The following is based on analysis of CC source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.
-### 执行顺序对照
+### Execution Order Comparison
-教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:
+The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:
-| 维度 | 教学版 | Claude Code |
-|------|--------|-------------|
-| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |
-| snip_compact | 保留头 3 + 尾 47 | CC 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |
-| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |
-| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |
-| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |
-| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |
-| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |
-| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |
-| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |
-| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |
-| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |
-| reactive 重试 | 1 次 | CC 有更精细的分级重试 |
+| Dimension | Teaching Version | Claude Code |
+|-----------|-----------------|-------------|
+| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |
+| snip_compact | Keep head 3 + tail 47 | CC only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |
+| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |
+| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |
+| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |
+| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |
+| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags |
+| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |
+| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |
+| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |
+| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |
+| Reactive retry | 1 time | CC has more granular tiered retries |
-### 执行顺序详解
+### Execution Order Details
-CC 源码 `query.ts` 中的真实顺序:
+The real order in CC source `query.ts`:
-1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘
-2. `snipCompact`(L403):裁中间消息
-3. `microcompact`(L414):旧结果占位
-4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)
-5. `autoCompact`(L454):LLM 全量摘要
+1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved
+2. `snipCompact` (L403): trim middle messages
+3. `microcompact` (L414): old result placeholders
+4. `contextCollapse` (L441): independent context management system (not in teaching version)
+5. `autoCompact` (L454): LLM full summary
-教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。
+The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.
-### read_file 的取舍
+### read_file Trade-off
-教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。
+The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates.
-Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。
+Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed.
-### 完整常量参考
+### Full Constant Reference
-| 常量 | 值 | 源文件 |
-|------|-----|--------|
+| Constant | Value | Source File |
+|----------|-------|-------------|
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
-| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |
+| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |
| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
-### contextCollapse 和 sessionMemoryCompact
+### contextCollapse and sessionMemoryCompact
-CC 源码中还有两个机制本教学版没有展开:
+CC source code has two additional mechanisms not covered in this teaching version:
-- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。
-- **sessionMemoryCompact**:compact_history 之前,CC 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。
+- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.
+- **sessionMemoryCompact**: Before compact_history, CC first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.
-### 压缩 prompt 长什么样?
+### What Does the Compression Prompt Look Like?
-CC 的压缩 prompt 有两个硬性要求:
+CC's compression prompt has two hard requirements:
-1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次
-2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离
+1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end
+2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting
-### 教学版的简化是刻意的
+### Teaching Version Simplifications Are Intentional
-- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限
-- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制
-- token 用字符数估算 → 精确 tokenizer 不在教学范围内
-- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件
-- 两个辅助机制不展开 → 属于 10% 的细节
+- micro_compact uses text placeholders → we don't have API-level `cache_edits` access
+- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery
+- Tokens estimated via character count → precise tokenizers are out of scope
+- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files
+- Two auxiliary mechanisms not covered → they fall in the 10% detail category
-核心设计思想,便宜的先跑贵的后跑,完整保留。
+The core design principle, cheap first, expensive last, is fully preserved.
diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md
new file mode 100644
index 00000000..f53026b0
--- /dev/null
+++ b/s08_context_compact/README.zh.md
@@ -0,0 +1,312 @@
+# s08: Context Compact — 上下文总会满,要有办法腾地方
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
+> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。
+>
+> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。
+
+---
+
+## 问题
+
+Agent 跑着跑着,不动了。
+
+手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。
+
+上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。
+
+不压缩,Agent 根本没法在大项目里干活。
+
+---
+
+## 解决方案
+
+
+
+保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。
+
+核心设计:便宜的先跑,贵的后跑。
+
+> **与 s09 的边界:** s08 管理当前会话有限的上下文,压缩时允许丢失细节;s09 另建持久存储,只保留需要跨压缩、跨会话存在的信息。两章解决的是不同故障,因此不合并。
+
+---
+
+## 工作原理
+
+
+
+### L1: snip_compact — 裁掉无关的旧对话
+
+Agent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的"帮我创建 hello.py"和当前工作几乎无关了,但全占着位置。
+
+消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开:
+
+```python
+def snip_compact(messages, max_messages=50):
+ if len(messages) <= max_messages:
+ return messages
+ head_end, tail_start = 3, len(messages) - (max_messages - 3)
+ if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):
+ while head_end < len(messages) and _is_tool_result_message(messages[head_end]):
+ head_end += 1
+ if (tail_start > 0 and tail_start < len(messages)
+ and _is_tool_result_message(messages[tail_start])
+ and _message_has_tool_use(messages[tail_start - 1])):
+ tail_start -= 1
+ snipped = tail_start - head_end
+ placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"}
+ return messages[:head_end] + [placeholder] + messages[tail_start:]
+```
+
+裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。
+
+### L2: micro_compact — 旧工具结果占位
+
+
+
+Agent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。
+
+只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符:
+
+```python
+KEEP_RECENT_TOOL_RESULTS = 3
+
+def micro_compact(messages):
+ tool_results = collect_tool_result_blocks(messages)
+ if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
+ return messages
+ for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
+ if len(block.get("content", "")) > 120:
+ block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
+ return messages
+```
+
+旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。
+
+### L3: tool_result_budget — 大结果落盘
+
+
+
+模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。
+
+统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。
+
+```python
+def tool_result_budget(messages, max_bytes=200_000):
+ last = messages[-1]
+ blocks = [(i, b) for i, b in enumerate(last["content"])
+ if b.get("type") == "tool_result"]
+ total = sum(len(str(b.get("content", ""))) for _, b in blocks)
+ if total <= max_bytes:
+ return messages
+ ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True)
+ for idx, block in ranked:
+ if total <= max_bytes:
+ break
+ block["content"] = persist_large_output(block["tool_use_id"], str(block["content"]))
+ total = recalculate_total(blocks)
+ return messages
+```
+
+前三层都是纯文本/结构操作,0 API 调用,但也无法"理解"对话内容。上下文可能仍然太大。→ L4。
+
+### L4: compact_history — LLM 全量摘要
+
+
+
+前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。
+
+三步流程:
+
+1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留了可恢复记录,但模型的活跃上下文里只剩摘要。对模型当下推理来说,细节已经不在上下文中了。教学代码没有提供 transcript 检索工具。
+2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。
+3. **替换消息列表**:所有旧消息被替换为一条摘要。教学版只保留摘要;真实 Claude Code 会在 compact 后重新附加部分最近文件、计划、agent/skill/tool 等上下文。
+
+```python
+def compact_history(messages):
+ transcript_path = write_transcript(messages) # 先保存完整对话
+ summary = summarize_history(messages) # LLM 生成摘要
+ return [{"role": "user",
+ "content": f"[Compacted]\n\n{summary}"}]
+```
+
+**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。
+
+### 应急: reactive_compact
+
+有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。
+
+这时触发 **reactive_compact**:触发方式比 compact_history 更激进(API 报错后的应急手段),但压缩策略更温和,保留最近约 5 条原始消息,只总结较早历史。同样避免留下孤立 `tool_result`。
+
+```python
+def reactive_compact(messages):
+ transcript = write_transcript(messages)
+ tail_start = max(0, len(messages) - 5)
+ if (tail_start > 0 and tail_start < len(messages)
+ and _is_tool_result_message(messages[tail_start])
+ and _message_has_tool_use(messages[tail_start - 1])):
+ tail_start -= 1
+ summary = summarize_history(messages[:tail_start])
+ return [{"role": "user",
+ "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]]
+```
+
+reactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。
+
+### 合起来跑
+
+```python
+def agent_loop(messages):
+ reactive_retries = 0
+ while True:
+ # 三个预处理器(0 API 调用)
+ # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪
+ messages[:] = tool_result_budget(messages) # L3: 大结果落盘
+ messages[:] = snip_compact(messages) # L1: 裁中间
+ messages[:] = micro_compact(messages) # L2: 旧结果占位
+
+ # 还不够?LLM 摘要(1 API 调用)
+ if estimate_token_count(messages) > THRESHOLD:
+ messages[:] = compact_history(messages)
+
+ try:
+ response = client.messages.create(...)
+ except PromptTooLongError:
+ if reactive_retries < MAX_REACTIVE_RETRIES:
+ messages[:] = reactive_compact(messages) # 应急
+ reactive_retries += 1
+ continue
+ raise # 超过重试上限,抛出异常
+ # ... 工具执行 ...
+
+ # compact 工具:模型主动调用时触发 compact_history
+ if block.name == "compact":
+ messages[:] = compact_history(messages)
+ results.append({..., "content": "[Compacted. History summarized.]"})
+ messages.append({"role": "user", "content": results})
+ break # 结束当前 turn,用压缩后的上下文开始新一轮
+```
+
+**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 tool_result 替换成一行占位符,budget 必须在那之前把完整内容落盘。这也是为什么 CC 源码把 `applyToolResultBudget` 放在最前面。
+
+---
+
+## 相对 s07 的变更
+
+| 组件 | 之前 (s07) | 之后 (s08) |
+|------|-----------|-----------|
+| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |
+| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |
+| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |
+| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |
+| 设计原则 | — | 便宜的先跑,贵的后跑 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s08_context_compact/code.py
+```
+
+试试这些 prompt:
+
+1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)
+2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)
+3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`
+
+观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?
+
+---
+
+## 接下来
+
+上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事?
+
+s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。
+
+### 执行顺序对照
+
+教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:
+
+| 维度 | 教学版 | Claude Code |
+|------|--------|-------------|
+| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |
+| snip_compact | 保留头 3 + 尾 47 | CC 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |
+| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |
+| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |
+| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |
+| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |
+| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |
+| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |
+| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |
+| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |
+| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |
+| reactive 重试 | 1 次 | CC 有更精细的分级重试 |
+
+### 执行顺序详解
+
+CC 源码 `query.ts` 中的真实顺序:
+
+1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘
+2. `snipCompact`(L403):裁中间消息
+3. `microcompact`(L414):旧结果占位
+4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)
+5. `autoCompact`(L454):LLM 全量摘要
+
+教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。
+
+### read_file 的取舍
+
+教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。
+
+Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。
+
+### 完整常量参考
+
+| 常量 | 值 | 源文件 |
+|------|-----|--------|
+| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
+| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
+| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
+| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
+| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
+| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
+| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |
+| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
+
+### contextCollapse 和 sessionMemoryCompact
+
+CC 源码中还有两个机制本教学版没有展开:
+
+- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。
+- **sessionMemoryCompact**:compact_history 之前,CC 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。
+
+### 压缩 prompt 长什么样?
+
+CC 的压缩 prompt 有两个硬性要求:
+
+1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次
+2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离
+
+### 教学版的简化是刻意的
+
+- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限
+- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制
+- token 用字符数估算 → 精确 tokenizer 不在教学范围内
+- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件
+- 两个辅助机制不展开 → 属于 10% 的细节
+
+核心设计思想,便宜的先跑贵的后跑,完整保留。
+
+
+
+
diff --git a/s09_memory/README.en.md b/s09_memory/README.en.md
deleted file mode 100644
index 2c5609f9..00000000
--- a/s09_memory/README.en.md
+++ /dev/null
@@ -1,279 +0,0 @@
-# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20
-> *"Compression loses details, keep a layer that doesn't"* — File store + index + on-demand loading, across compactions, across sessions.
->
-> **Harness Layer**: Memory — knowledge that survives compaction and sessions.
-
----
-
-## The Problem
-
-s08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: "use tabs not spaces" might get simplified to "user has code style preferences". And when you start a new session, even the summary is gone.
-
-LLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. What's needed is a storage layer that doesn't participate in compression and persists across sessions.
-
----
-
-## The Solution
-
-
-
-The s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM.
-
-Key design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says "remember", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates.
-
-Four memory types, each answering a different question:
-
-| Type | Answers | Example |
-|------|---------|---------|
-| user | Who you are | "Use tabs not spaces" |
-| feedback | How to work | "Don't mock the database" |
-| project | What's happening | "Auth rewrite is compliance-driven" |
-| reference | Where to find things | "Pipeline bugs are in Linear INGEST" |
-
----
-
-## How It Works
-
-
-
-### Storage: Markdown Files + Index
-
-Each memory is a `.md` file with YAML frontmatter for metadata:
-
-```markdown
----
-name: user-preference-tabs
-description: User prefers tabs for indentation
-type: user
----
-
-User prefers using tabs, not spaces, for indentation.
-**Why:** Consistency with existing codebase conventions.
-**How to apply:** Always use tabs when writing or editing files.
-```
-
-`MEMORY.md` is the index, one link per line:
-
-```markdown
-- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
-```
-
-Writing a new memory automatically rebuilds the index:
-
-```python
-def write_memory_file(name, mem_type, description, body):
- slug = name.lower().replace(" ", "-")
- filepath = MEMORY_DIR / f"{slug}.md"
- filepath.write_text(
- f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
- )
- _rebuild_index()
-```
-
-### Loading: Two Paths
-
-**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` once at the start of each user request and injects the memory catalog into the SYSTEM prompt. Memory extraction and consolidation run only when the turn ends, so SYSTEM does not need to be rebuilt repeatedly within the same user request.
-
-**Path 2: Relevant memories on demand.** At the start of each user request, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost.
-
-```python
-def select_relevant_memories(messages, max_items=5):
- files = list_memory_files()
- if not files:
- return []
-
- # Build catalog: "0: user-preference-tabs — User prefers tabs..."
- catalog = "\n".join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files))
-
- response = client.messages.create(model=MODEL, messages=[{"role": "user",
- "content": f"Select relevant memory indices. Return JSON array.\n\n"
- f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}],
- max_tokens=200)
- indices = json.loads(re.search(r'\[.*?\]', response.content[0].text).group())
- return [files[i]["filename"] for i in indices if 0 <= i < len(files)]
-```
-
-If the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description.
-
-### Writing: Extraction After Each Turn
-
-Users don't always say "remember this". Preferences are usually scattered across normal dialogue: "tabs are better than spaces", "let's use single quotes from now on".
-
-`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break):
-
-```python
-# In agent_loop:
-if response.stop_reason != "tool_use":
- extract_memories(messages) # Extract new memories from recent dialogue
- consolidate_memories() # Check if consolidation is needed
- return
-```
-
-Before extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found.
-
-```python
-def extract_memories(messages):
- dialogue = format_recent_messages(messages[-10:])
- existing = "\n".join(f"- {m['name']}: {m['description']}" for m in list_memory_files())
-
- prompt = (
- "Extract user preferences, constraints, or project facts.\n"
- "Return JSON array: [{name, type, description, body}].\n"
- "If nothing new or already covered, return [].\n\n"
- f"Existing memories:\n{existing}\n\nDialogue:\n{dialogue[:4000]}"
- )
- # ... parse response, write files ...
-```
-
-### Consolidation: Low-Frequency Deduplication
-
-Memory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories:
-
-```python
-CONSOLIDATE_THRESHOLD = 10
-
-def consolidate_memories():
- files = list_memory_files()
- if len(files) < CONSOLIDATE_THRESHOLD:
- return # Too few, not worth consolidating
- # Send all memories to LLM, get back deduplicated list
- # Replace all files with consolidated results
-```
-
-CC calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold.
-
-### What Memory Stores
-
-Memory stores information that remains useful across sessions: user preferences, recurring feedback, project background, common entry points, and investigation clues. It focuses on "what will be useful later" and brings that information back through an index plus on-demand loading.
-
-Session memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction.
-
----
-
-## Changes From s08
-
-| Component | Before (s08) | After (s09) |
-|-----------|-------------|-------------|
-| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation |
-| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |
-| Storage | — | .memory/MEMORY.md index + .memory/*.md files |
-| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |
-| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s09_memory/code.py
-```
-
-Try these prompts (enter across multiple turns, observe memory accumulation and loading):
-
-1. `I prefer using tabs for indentation, not spaces. Remember that.`
-2. `Create a Python file called test.py` (observe whether the Agent uses tabs)
-3. `What did I tell you about my preferences?` (observe whether the Agent remembers)
-4. `I also prefer single quotes over double quotes for strings.`
-
-What to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations?
-
----
-
-## What's Next
-
-Memory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime.
-
-s10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.
-
-
-Deep Dive Into CC Source Code
-
-> The following is based on analysis of CC source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source.
-
-### Source Code Paths
-
-| File | Lines | Responsibility |
-|------|-------|---------------|
-| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) |
-| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) |
-| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields |
-| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) |
-| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) |
-| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) |
-| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management |
-| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) |
-| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) |
-| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) |
-| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) |
-
-### Memory Selection: LLM, Not Embedding
-
-CC uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity:
-
-1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending
-2. Lists all memory files' `name` + `description` as a catalog
-3. Sends to Sonnet side-query: "Select truly useful memories by name and description (max 5). Skip if unsure."
-4. Sonnet returns `{ selected_memories: ["file1.md", ...] }`
-5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB
-
-At the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking.
-
-### Extraction Timing: Stop Hook, Not After autoCompact
-
-Trigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != "tool_use"` branch, matching the direction.
-
-CC's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped.
-
-### Memory File Format
-
-CC uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`.
-
-`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`.
-
-### Dream: Four-Layer Gating
-
-Not "triggered when idle" or "consolidate when count is enough", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`):
-
-1. **Time gate**: ≥ 24 hours since last consolidation
-2. **Scan throttle**: Avoid frequent filesystem scans
-3. **Session gate**: ≥ 5 session transcripts modified since last consolidation
-4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file)
-
-The merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour.
-
-### User Memory vs Session Memory
-
-| | User Memory | Session Memory |
-|---|---|---|
-| Persistence | Cross-session | Single session |
-| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` |
-| Loaded into | system prompt | compact summary |
-| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity |
-
-sessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM.
-
-### Where the Real Implementation Is More Complex
-
-- **Feature flags**: Memory features have multiple feature gate layers
-- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version)
-- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()`
-- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn
-- **File locks**: Concurrency control for multi-process scenarios
-- **Memory prefetch**: Async prefetch, non-blocking main flow
-
-### Teaching Version Simplifications Are Intentional
-
-- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path
-- Memory JSON → Markdown + frontmatter: teaching version matches CC
-- Stop hook trigger → `stop_reason != "tool_use"` branch: same direction
-- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts
-- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation
-
-
-
-
diff --git a/s09_memory/README.ja.md b/s09_memory/README.ja.md
index f9e5a2c4..96864f40 100644
--- a/s09_memory/README.ja.md
+++ b/s09_memory/README.ja.md
@@ -1,8 +1,8 @@
# s09: Memory — 圧縮は詳細を失う、失わない層が必要
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20
+s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
> *"圧縮は詳細を失う、失わない層が必要"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。
>
> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積。
@@ -25,6 +25,8 @@ s08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ス
重要な設計:インデックスは SYSTEM prompt に常駐(prompt cache でキャッシュ可能)、ファイル内容はオンデマンド注入(filename/description で現在の会話にマッチ、cache を破壊しない)。書き込みは 2 つのパス:ユーザーが明示的に「覚えて」と言うか、毎ターン終了後にバックグラウンドで抽出。ファイルが蓄積されたら、定期的に整理して重複排除。
+> **s08 との境界:** 圧縮は引き続き現在の会話と token 予算を担当する。記憶は圧縮を置き換えず、選んだ事実を会話の外に保存し、後から必要に応じて呼び戻す。
+
4 種類の記憶、それぞれ異なる質問に答える:
| タイプ | 何に答えるか | 例 |
diff --git a/s09_memory/README.md b/s09_memory/README.md
index 88e5f6a6..79a592b4 100644
--- a/s09_memory/README.md
+++ b/s09_memory/README.md
@@ -1,48 +1,50 @@
-# s09: Memory — 压缩会丢细节,要有一层不丢的
+# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20
-> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。
+s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
+> *"Compression loses details, keep a layer that doesn't"* — File store + index + on-demand loading, across compactions, across sessions.
>
-> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。
+> **Harness Layer**: Memory — knowledge that survives compaction and sessions.
---
-## 问题
+## The Problem
-s08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:"用 tab 缩进不要用空格"可能被简化成"用户有代码风格偏好"。而且新开一个会话,连摘要也没了。
+s08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: "use tabs not spaces" might get simplified to "user has code style preferences". And when you start a new session, even the summary is gone.
-LLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层不参与压缩、跨会话保留的存储。
+LLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. What's needed is a storage layer that doesn't participate in compression and persists across sessions.
---
-## 解决方案
+## The Solution
-
+
-s08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。
+The s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM.
-关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说"记住"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。
+Key design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says "remember", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates.
-四类记忆,各有用途:
+> **Boundary with s08:** compaction still owns the current transcript and token budget. Memory does not replace that pipeline; it selectively persists facts outside the transcript and recalls them later.
-| 类型 | 回答什么 | 示例 |
-|------|---------|------|
-| user | 你是谁 | "用 tab 不用空格" |
-| feedback | 怎么做事 | "别 mock 数据库" |
-| project | 正在发生什么 | "auth 重写是合规驱动" |
-| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" |
+Four memory types, each answering a different question:
+
+| Type | Answers | Example |
+|------|---------|---------|
+| user | Who you are | "Use tabs not spaces" |
+| feedback | How to work | "Don't mock the database" |
+| project | What's happening | "Auth rewrite is compliance-driven" |
+| reference | Where to find things | "Pipeline bugs are in Linear INGEST" |
---
-## 工作原理
+## How It Works
-
+
-### 存储:Markdown 文件 + 索引
+### Storage: Markdown Files + Index
-每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据:
+Each memory is a `.md` file with YAML frontmatter for metadata:
```markdown
---
@@ -56,13 +58,13 @@ User prefers using tabs, not spaces, for indentation.
**How to apply:** Always use tabs when writing or editing files.
```
-`MEMORY.md` 是索引,一行一个链接:
+`MEMORY.md` is the index, one link per line:
```markdown
- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
```
-写入新记忆时自动重建索引:
+Writing a new memory automatically rebuilds the index:
```python
def write_memory_file(name, mem_type, description, body):
@@ -74,11 +76,11 @@ def write_memory_file(name, mem_type, description, body):
_rebuild_index()
```
-### 加载:两条路径
+### Loading: Two Paths
-**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。
+**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` once at the start of each user request and injects the memory catalog into the SYSTEM prompt. Memory extraction and consolidation run only when the turn ends, so SYSTEM does not need to be rebuilt repeatedly within the same user request.
-**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。
+**Path 2: Relevant memories on demand.** At the start of each user request, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost.
```python
def select_relevant_memories(messages, max_items=5):
@@ -93,28 +95,27 @@ def select_relevant_memories(messages, max_items=5):
"content": f"Select relevant memory indices. Return JSON array.\n\n"
f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}],
max_tokens=200)
- text = extract_text(response.content).strip()
- indices = json.loads(re.search(r'\[.*?\]', text).group())
+ indices = json.loads(re.search(r'\[.*?\]', response.content[0].text).group())
return [files[i]["filename"] for i in indices if 0 <= i < len(files)]
```
-如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。
+If the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description.
-### 写入:每轮结束后提取
+### Writing: Extraction After Each Turn
-用户不会每次都说"记住这个"。偏好通常散落在正常对话中:"用 tab 比空格好"、"以后都用单引号"。
+Users don't always say "remember this". Preferences are usually scattered across normal dialogue: "tabs are better than spaces", "let's use single quotes from now on".
-`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落):
+`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break):
```python
# In agent_loop:
if response.stop_reason != "tool_use":
- extract_memories(pre_compress) # 从压缩前快照提取新记忆
- consolidate_memories() # 检查是否需要整理
+ extract_memories(messages) # Extract new memories from recent dialogue
+ consolidate_memories() # Check if consolidation is needed
return
```
-提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。
+Before extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found.
```python
def extract_memories(messages):
@@ -130,9 +131,9 @@ def extract_memories(messages):
# ... parse response, write files ...
```
-### 整理:低频合并去重
+### Consolidation: Low-Frequency Deduplication
-记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆:
+Memory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories:
```python
CONSOLIDATE_THRESHOLD = 10
@@ -140,140 +141,140 @@ CONSOLIDATE_THRESHOLD = 10
def consolidate_memories():
files = list_memory_files()
if len(files) < CONSOLIDATE_THRESHOLD:
- return # 太少,不值得整理
+ return # Too few, not worth consolidating
# Send all memories to LLM, get back deduplicated list
# Replace all files with consolidated results
```
-CC 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。
+CC calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold.
-### Memory 适合保存什么
+### What Memory Stores
-Memory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。
+Memory stores information that remains useful across sessions: user preferences, recurring feedback, project background, common entry points, and investigation clues. It focuses on "what will be useful later" and brings that information back through an index plus on-demand loading.
-session memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。
+Session memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction.
---
-## 相对 s08 的变更
+## Changes From s08
-| 组件 | 之前 (s08) | 之后 (s09) |
-|------|-----------|-----------|
-| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 |
-| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |
-| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 |
-| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |
-| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 |
+| Component | Before (s08) | After (s09) |
+|-----------|-------------|-------------|
+| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation |
+| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |
+| Storage | — | .memory/MEMORY.md index + .memory/*.md files |
+| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |
+| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s09_memory/code.py
```
-试试这些 prompt(分多轮输入,观察记忆的累积和加载):
+Try these prompts (enter across multiple turns, observe memory accumulation and loading):
1. `I prefer using tabs for indentation, not spaces. Remember that.`
-2. `Create a Python file called test.py`(观察 Agent 是否用了 tab)
-3. `What did I tell you about my preferences?`(观察 Agent 是否记得)
+2. `Create a Python file called test.py` (observe whether the Agent uses tabs)
+3. `What did I tell you about my preferences?` (observe whether the Agent remembers)
4. `I also prefer single quotes over double quotes for strings.`
-观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?
+What to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations?
---
-## 接下来
+## What's Next
-记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。
+Memory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime.
-s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。
+s10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.
-深入 CC 源码
+Deep Dive Into CC Source Code
-> 以下基于 CC 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。
+> The following is based on analysis of CC source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source.
-### 源码路径
+### Source Code Paths
-| 文件 | 行数 | 职责 |
-|------|------|------|
-| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) |
-| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) |
-| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 |
-| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) |
-| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) |
-| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) |
-| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 |
-| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) |
-| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) |
-| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) |
-| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) |
+| File | Lines | Responsibility |
+|------|-------|---------------|
+| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) |
+| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) |
+| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields |
+| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) |
+| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) |
+| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) |
+| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management |
+| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) |
+| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) |
+| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) |
+| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) |
-### 记忆选择:LLM 选,不是 embedding
+### Memory Selection: LLM, Not Embedding
-CC 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度:
+CC uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity:
-1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序
-2. 把 `name` + `description` 列成清单
-3. 发给 Sonnet side-query:"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。"
-4. Sonnet 返回 `{ selected_memories: ["file1.md", ...] }`
-5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB
+1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending
+2. Lists all memory files' `name` + `description` as a catalog
+3. Sends to Sonnet side-query: "Select truly useful memories by name and description (max 5). Skip if unsure."
+4. Sonnet returns `{ selected_memories: ["file1.md", ...] }`
+5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB
-每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。
+At the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking.
-### 提取时机:stop hook,不是 autoCompact 后
+### Extraction Timing: Stop Hook, Not After autoCompact
-触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != "tool_use"` 分支里,方向一致。
+Trigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != "tool_use"` branch, matching the direction.
-CC 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。
+CC's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped.
-### 记忆文件格式
+### Memory File Format
-CC 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。
+CC uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`.
-`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。
+`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`.
-### Dream:四层门控
+### Dream: Four-Layer Gating
-不是"空闲时触发"或"数量够了就合并",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`):
+Not "triggered when idle" or "consolidate when count is enough", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`):
-1. **时间门控**:距上次合并 ≥ 24 小时
-2. **扫描节流**:避免频繁扫描文件系统
-3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript
-4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件)
+1. **Time gate**: ≥ 24 hours since last consolidation
+2. **Scan throttle**: Avoid frequent filesystem scans
+3. **Session gate**: ≥ 5 session transcripts modified since last consolidation
+4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file)
-合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。
+The merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour.
### User Memory vs Session Memory
| | User Memory | Session Memory |
|---|---|---|
-| 持久性 | 跨会话 | 单会话 |
-| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` |
-| 加载到 | system prompt | compact 摘要 |
-| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 |
+| Persistence | Cross-session | Single session |
+| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` |
+| Loaded into | system prompt | compact summary |
+| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity |
-sessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。
+sessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM.
-### 真实实现比教学版复杂的地方
+### Where the Real Implementation Is More Complex
-- **Feature flags**:记忆相关功能有多层 feature gate 控制
-- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及)
-- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式
-- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容
-- **文件锁**:多进程并发时的锁机制
-- **Memory prefetch**:异步预取,不阻塞主流程
+- **Feature flags**: Memory features have multiple feature gate layers
+- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version)
+- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()`
+- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn
+- **File locks**: Concurrency control for multi-process scenarios
+- **Memory prefetch**: Async prefetch, non-blocking main flow
-### 教学版的简化是刻意的
+### Teaching Version Simplifications Are Intentional
-- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径
-- 记忆 JSON → Markdown + frontmatter:教学版与 CC 一致
-- stop hook 触发 → `stop_reason != "tool_use"` 分支:方向一致
-- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念
-- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离
+- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path
+- Memory JSON → Markdown + frontmatter: teaching version matches CC
+- Stop hook trigger → `stop_reason != "tool_use"` branch: same direction
+- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts
+- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation
diff --git a/s09_memory/README.zh.md b/s09_memory/README.zh.md
new file mode 100644
index 00000000..0f21c69f
--- /dev/null
+++ b/s09_memory/README.zh.md
@@ -0,0 +1,282 @@
+# s09: Memory — 压缩会丢细节,要有一层不丢的
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
+> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。
+>
+> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。
+
+---
+
+## 问题
+
+s08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:"用 tab 缩进不要用空格"可能被简化成"用户有代码风格偏好"。而且新开一个会话,连摘要也没了。
+
+LLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层不参与压缩、跨会话保留的存储。
+
+---
+
+## 解决方案
+
+
+
+s08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。
+
+关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说"记住"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。
+
+> **与 s08 的边界:** 压缩仍负责当前对话和 token 预算;记忆不会取代压缩管线,而是把选中的事实存到对话之外,并在之后按需召回。
+
+四类记忆,各有用途:
+
+| 类型 | 回答什么 | 示例 |
+|------|---------|------|
+| user | 你是谁 | "用 tab 不用空格" |
+| feedback | 怎么做事 | "别 mock 数据库" |
+| project | 正在发生什么 | "auth 重写是合规驱动" |
+| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" |
+
+---
+
+## 工作原理
+
+
+
+### 存储:Markdown 文件 + 索引
+
+每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据:
+
+```markdown
+---
+name: user-preference-tabs
+description: User prefers tabs for indentation
+type: user
+---
+
+User prefers using tabs, not spaces, for indentation.
+**Why:** Consistency with existing codebase conventions.
+**How to apply:** Always use tabs when writing or editing files.
+```
+
+`MEMORY.md` 是索引,一行一个链接:
+
+```markdown
+- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
+```
+
+写入新记忆时自动重建索引:
+
+```python
+def write_memory_file(name, mem_type, description, body):
+ slug = name.lower().replace(" ", "-")
+ filepath = MEMORY_DIR / f"{slug}.md"
+ filepath.write_text(
+ f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
+ )
+ _rebuild_index()
+```
+
+### 加载:两条路径
+
+**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。
+
+**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。
+
+```python
+def select_relevant_memories(messages, max_items=5):
+ files = list_memory_files()
+ if not files:
+ return []
+
+ # Build catalog: "0: user-preference-tabs — User prefers tabs..."
+ catalog = "\n".join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files))
+
+ response = client.messages.create(model=MODEL, messages=[{"role": "user",
+ "content": f"Select relevant memory indices. Return JSON array.\n\n"
+ f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}],
+ max_tokens=200)
+ text = extract_text(response.content).strip()
+ indices = json.loads(re.search(r'\[.*?\]', text).group())
+ return [files[i]["filename"] for i in indices if 0 <= i < len(files)]
+```
+
+如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。
+
+### 写入:每轮结束后提取
+
+用户不会每次都说"记住这个"。偏好通常散落在正常对话中:"用 tab 比空格好"、"以后都用单引号"。
+
+`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落):
+
+```python
+# In agent_loop:
+if response.stop_reason != "tool_use":
+ extract_memories(pre_compress) # 从压缩前快照提取新记忆
+ consolidate_memories() # 检查是否需要整理
+ return
+```
+
+提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。
+
+```python
+def extract_memories(messages):
+ dialogue = format_recent_messages(messages[-10:])
+ existing = "\n".join(f"- {m['name']}: {m['description']}" for m in list_memory_files())
+
+ prompt = (
+ "Extract user preferences, constraints, or project facts.\n"
+ "Return JSON array: [{name, type, description, body}].\n"
+ "If nothing new or already covered, return [].\n\n"
+ f"Existing memories:\n{existing}\n\nDialogue:\n{dialogue[:4000]}"
+ )
+ # ... parse response, write files ...
+```
+
+### 整理:低频合并去重
+
+记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆:
+
+```python
+CONSOLIDATE_THRESHOLD = 10
+
+def consolidate_memories():
+ files = list_memory_files()
+ if len(files) < CONSOLIDATE_THRESHOLD:
+ return # 太少,不值得整理
+ # Send all memories to LLM, get back deduplicated list
+ # Replace all files with consolidated results
+```
+
+CC 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。
+
+### Memory 适合保存什么
+
+Memory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。
+
+session memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。
+
+---
+
+## 相对 s08 的变更
+
+| 组件 | 之前 (s08) | 之后 (s09) |
+|------|-----------|-----------|
+| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 |
+| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |
+| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 |
+| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |
+| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s09_memory/code.py
+```
+
+试试这些 prompt(分多轮输入,观察记忆的累积和加载):
+
+1. `I prefer using tabs for indentation, not spaces. Remember that.`
+2. `Create a Python file called test.py`(观察 Agent 是否用了 tab)
+3. `What did I tell you about my preferences?`(观察 Agent 是否记得)
+4. `I also prefer single quotes over double quotes for strings.`
+
+观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?
+
+---
+
+## 接下来
+
+记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。
+
+s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。
+
+### 源码路径
+
+| 文件 | 行数 | 职责 |
+|------|------|------|
+| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) |
+| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) |
+| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 |
+| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) |
+| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) |
+| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) |
+| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 |
+| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) |
+| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) |
+| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) |
+| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) |
+
+### 记忆选择:LLM 选,不是 embedding
+
+CC 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度:
+
+1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序
+2. 把 `name` + `description` 列成清单
+3. 发给 Sonnet side-query:"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。"
+4. Sonnet 返回 `{ selected_memories: ["file1.md", ...] }`
+5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB
+
+每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。
+
+### 提取时机:stop hook,不是 autoCompact 后
+
+触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != "tool_use"` 分支里,方向一致。
+
+CC 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。
+
+### 记忆文件格式
+
+CC 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。
+
+`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。
+
+### Dream:四层门控
+
+不是"空闲时触发"或"数量够了就合并",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`):
+
+1. **时间门控**:距上次合并 ≥ 24 小时
+2. **扫描节流**:避免频繁扫描文件系统
+3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript
+4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件)
+
+合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。
+
+### User Memory vs Session Memory
+
+| | User Memory | Session Memory |
+|---|---|---|
+| 持久性 | 跨会话 | 单会话 |
+| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` |
+| 加载到 | system prompt | compact 摘要 |
+| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 |
+
+sessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。
+
+### 真实实现比教学版复杂的地方
+
+- **Feature flags**:记忆相关功能有多层 feature gate 控制
+- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及)
+- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式
+- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容
+- **文件锁**:多进程并发时的锁机制
+- **Memory prefetch**:异步预取,不阻塞主流程
+
+### 教学版的简化是刻意的
+
+- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径
+- 记忆 JSON → Markdown + frontmatter:教学版与 CC 一致
+- stop hook 触发 → `stop_reason != "tool_use"` 分支:方向一致
+- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念
+- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离
+
+
+
+
diff --git a/s10_system_prompt/README.en.md b/s10_system_prompt/README.en.md
deleted file mode 100644
index bcf1811a..00000000
--- a/s10_system_prompt/README.en.md
+++ /dev/null
@@ -1,255 +0,0 @@
-# s10: System Prompt — Assembled at Runtime, Never Hardcoded
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
-> *"prompt is assembled, not hardcoded"* — Sections + on-demand assembly + caching.
->
-> **Harness Layer**: Prompt — assembled at runtime, never hardcoded.
-
----
-
-## The Problem
-
-From s01 to s09, the system prompt was always one hardcoded line:
-
-```python
-SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
-```
-
-That worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:
-
-```python
-SYSTEM = (
- f"You are a coding agent at {WORKDIR}. "
- "Use tools to solve tasks. Act, don't explain. "
- "Before starting any multi-step task, use todo_write. "
- "Skills are available via list_skills and load_skill. "
- "Relevant memories are injected below when available. "
- # ... add a capability, add a line
-)
-```
-
-Three problems:
-
-1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep
-2. **One change can break others** — adding a tool description might conflict with earlier instructions
-3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens
-
-The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.
-
----
-
-## The Solution
-
-
-
-s10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result.
-
-Four sections, two loading strategies:
-
-| Section | Strategy | Content | Condition |
-|---------|----------|---------|-----------|
-| identity | always | who you are, how to work | always present |
-| tools | always | available tool list | `enabled_tools` |
-| workspace | always | working directory | always present |
-| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |
-
-Key design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages.
-
----
-
-## How It Works
-
-### PROMPT_SECTIONS: Topic-Keyed Fragments
-
-Split the monolithic string into a dictionary, each key is a topic:
-
-```python
-PROMPT_SECTIONS = {
- "identity": "You are a coding agent. Act, don't explain.",
-}
-```
-
-Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.
-
-### assemble_system_prompt: On-Demand Assembly
-
-Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:
-
-```python
-def assemble_system_prompt(context: dict) -> str:
- sections = []
-
- # Always loaded
- sections.append(PROMPT_SECTIONS["identity"])
-
- # Dynamic — tools and workspace from context
- tools = ", ".join(context.get("enabled_tools", []))
- if tools:
- sections.append(f"Available tools: {tools}.")
- sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
-
- # On-demand — based on real state, not keywords
- memories = context.get("memories", "")
- if memories:
- sections.append(f"Relevant memories:\n{memories}")
-
- return "\n\n".join(sections)
-```
-
-"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions.
-
-Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).
-
-### get_system_prompt: Cache to Avoid Re-Assembly
-
-When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:
-
-```python
-def get_system_prompt(context: dict) -> str:
- global _last_context_key, _last_prompt
- key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
- if key == _last_context_key and _last_prompt:
- return _last_prompt
- _last_context_key = key
- _last_prompt = assemble_system_prompt(context)
- return _last_prompt
-```
-
-`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.
-
-Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.
-
-### context: Real State, Not Keyword Guessing
-
-Context reflects the actual runtime state:
-
-```python
-def update_context(context: dict, messages: list) -> dict:
- memories = ""
- if MEMORY_INDEX.exists():
- content = MEMORY_INDEX.read_text().strip()
- if content:
- memories = content
- return {
- "enabled_tools": list(TOOL_HANDLERS.keys()),
- "workspace": str(WORKDIR),
- "memories": memories,
- }
-```
-
-`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages.
-
-### Putting It Together
-
-```python
-def agent_loop(messages: list, context: dict):
- system = get_system_prompt(context)
- while True:
- response = client.messages.create(
- model=MODEL, system=system, messages=messages,
- tools=TOOLS, max_tokens=8000)
- # ... tool execution ...
- context = update_context(context, messages)
- system = get_system_prompt(context)
-```
-
-At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.
-
----
-
-## Changes From s09
-
-| Component | Before (s09) | After (s10) |
-|-----------|-------------|-------------|
-| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |
-| caching | None | get_system_prompt (json.dumps detection + cache) |
-| new functions | — | assemble_system_prompt, get_system_prompt, update_context |
-| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
-| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s10_system_prompt/code.py
-```
-
-What to watch for:
-
-1. Output shows which sections were loaded (`[assembled] sections: ...` label)
-2. Cache hits show `[cache hit]` during continued conversation
-3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn
-
-Try these prompts:
-
-1. `Read the file README.md` (observe the three always-loaded sections)
-2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index)
-3. `Read the file code.py` (observe whether the memory section appears)
-
----
-
-## What's Next
-
-System prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal.
-
-s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.
-
-
-Deep Dive Into CC Source Code
-
-> The following is based on analysis of CC source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`.
-
-### How many sections does CC's system prompt have?
-
-The count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories:
-
-**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc.
-
-**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc.
-
-`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns.
-
-### Assembly Function
-
-```typescript
-getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise
-```
-
-Returns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts.
-
-### cache scope
-
-When global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope.
-
-The teaching version's cache only avoids redundant string assembly. CC's three-layer cache:
-
-1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`)
-2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact`
-3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary
-
-### getUserContext vs getSystemContext
-
-| | getSystemContext | getUserContext |
-|---|---|---|
-| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate |
-| Injection | appended to system prompt array | prepended as `` user message |
-| When skipped | custom system prompt | always runs |
-
-### How modes change the prompt
-
-- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines
-- **Proactive/KAIROS**: compact prompt replaces all standard sections
-- **Coordinator**: coordinator-specific prompt fully replaces default
-- **Agent mode**: agent-defined prompt replaces or appends to default
-
-### Total size
-
-Standard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top.
-
-
-
-
diff --git a/s10_system_prompt/README.ja.md b/s10_system_prompt/README.ja.md
index cfe8f95b..69db8117 100644
--- a/s10_system_prompt/README.ja.md
+++ b/s10_system_prompt/README.ja.md
@@ -1,11 +1,11 @@
-# s10: System Prompt — 実行時アセンブリ、ハードコードなし
+# s10: Context Assembly — 実行時にモデル入力を組み立てる
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
-> *"prompt は組み立てるもの、固定するものではない"* — セグメント + オンデマンド結合 + キャッシュ。
+s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
+> *"モデル入力は組み立てるもの、固定するものではない"* — 安定セクション + 実行時状態 + キャッシュ。
>
-> **Harness レイヤー**: プロンプト — 実行時組み立て、ハードコードなし。
+> **Harness レイヤー**: コンテキスト組み立て — 安定した指示と動的状態をモデル入力にまとめる。
---
@@ -44,7 +44,7 @@ System prompt は、実行時の現在状態に基づいて組み立てられる

-s10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を背景とするが、圧縮や記憶システムは再実装しない。核心の変更:ハードコードされた `SYSTEM` を独立セクションに分割し、実行時に実際の状態に基づいてオンデマンドで組み立て、結果をキャッシュして再組み立てを回避。
+s10 はコンテキスト管理とエラー回復をつなぐ短い橋渡しセッションである。新しいストレージを追加せず、s08 と s09 も統合しない。両者の出力がモデル境界でどう合流するかを示す:ハードコードされた `SYSTEM` を独立セクションに分割し、実際の実行時状態から組み立て、結果をキャッシュする。
4 つのセクション、2 つの読み込み戦略:
@@ -88,7 +88,7 @@ def assemble_system_prompt(context: dict) -> str:
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
- sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
+ sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# オンデマンド — 実際の状態に基づく、キーワードではない
memories = context.get("memories", "")
@@ -178,6 +178,8 @@ cd learn-claude-code
python s10_system_prompt/code.py
```
+安全上の注意:この集中教材はモデルが渡す `bash` 文字列を `shell=True` で実行し、s03 の permission gate を省略している。破棄可能な workspace だけで実行し、本番 harness では permission と sandbox の境界を戻すこと。
+
観察のポイント:
1. 出力にロードされたセクションが表示される(`[assembled] sections: ...` ラベル)
@@ -194,62 +196,8 @@ python s10_system_prompt/code.py
## 次へ
-System prompt を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。
+モデル入力を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。
s11 Error Recovery → 4 つのリカバリパス。token のアップグレード、コンテキスト圧縮、指数バックオフ、モデル切り替え。
-
-CC ソースコードの詳細
-
-> 以下は CC ソースコード `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` の分析に基づく。
-
-### CC の system prompt にはいくつのセクションがあるか?
-
-数は固定されておらず、feature flag、output style、KAIROS/Proactive モード、ユーザータイプ、token 予算などに影響される。大まかに 2 つのカテゴリ:
-
-**静的セクション**(常にロード):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency など。
-
-**動的セクション**(状態に応じてロード):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief など。
-
-`mcp_instructions` は唯一の揮発性セクション(`DANGEROUS_uncachedSystemPromptSection()` で作成)。MCP server はターン間で接続・切断可能なため。
-
-### 組み立て関数
-
-```typescript
-getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise
-```
-
-`string[]`(各要素がセクション)を返却。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離。
-
-### cache scope
-
-global cache boundary が有効な場合、静的セクションは 1 つの global cache block にマージされ、動的セクションは global cache を使用しない(`cacheScope: null`)。boundary なしまたは global cache をスキップするパスでのみ org scope にフォールバック。
-
-教学版のキャッシュは文字列の再組み立てを回避するのみ。CC の 3 層キャッシュ:
-
-1. **lodash memoize**: `getSystemContext` と `getUserContext` がセッション中キャッシュ(`context.ts`)
-2. **セクション登録キャッシュ**: `STATE.systemPromptSectionCache` が動的セクションの結果をキャッシュ、`/clear` や `/compact` でクリア
-3. **API レベルキャッシュ**: `splitSysPromptPrefix()`(`api.ts`)が boundary を通じて異なる cache scope のブロックに分割
-
-### getUserContext vs getSystemContext
-
-| | getSystemContext | getUserContext |
-|---|---|---|
-| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |
-| 注入方式 | system prompt 配列に追加 | `` ユーザーメッセージとして先頭に配置 |
-| スキップ条件 | カスタム system prompt 時 | 常に実行 |
-
-### モードによる prompt の変化
-
-- **CLAUDE_CODE_SIMPLE**: prompt 全体が 2 行のみ
-- **Proactive/KAIROS**: コンパクト版 prompt が標準セクション全体を置換
-- **Coordinator**: コーディネータ専用 prompt がデフォルトを完全に置換
-- **Agent モード**: Agent 定義の prompt がデフォルトを置換または追加
-
-### 総サイズ
-
-標準インタラクティブモードの system prompt コアは約 20-30KB テキスト。CLAUDE_CODE_SIMPLE は約 150 文字。ユーザーコンテキスト(CLAUDE.md)とシステムコンテキスト(git status)がこれに加算。
-
-
-
diff --git a/s10_system_prompt/README.md b/s10_system_prompt/README.md
index 0cd95930..67bf59e5 100644
--- a/s10_system_prompt/README.md
+++ b/s10_system_prompt/README.md
@@ -1,23 +1,23 @@
-# s10: System Prompt — 运行时组装,不硬编码
+# s10: Context Assembly — Build Model Input at Runtime
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
-> *"prompt 是组装出来的, 不是写死的"* — 分段 + 按需拼接 + 缓存。
+s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
+> *"Model input is assembled, not hardcoded"* — Stable sections + runtime state + caching.
>
-> **Harness 层**: 提示 — 运行时组装, 不硬编码。
+> **Harness Layer**: Context assembly — turn stable instructions and dynamic state into model input.
---
-## 问题
+## The Problem
-从 s01 到 s09,system prompt 都是一行硬编码:
+From s01 to s09, the system prompt was always one hardcoded line:
```python
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
```
-s01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:
+That worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:
```python
SYSTEM = (
@@ -26,44 +26,44 @@ SYSTEM = (
"Before starting any multi-step task, use todo_write. "
"Skills are available via list_skills and load_skill. "
"Relevant memories are injected below when available. "
- # ... 加一个能力就多一段
+ # ... add a capability, add a line
)
```
-三个问题:
+Three problems:
-1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留
-2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突
-3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token
+1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep
+2. **One change can break others** — adding a tool description might conflict with earlier instructions
+3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens
-System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。
+The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.
---
-## 解决方案
+## The Solution
-
+
-s10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,缓存结果避免重复组装。
+s10 is a short bridge between context management and recovery. It does not add another storage system or merge s08 with s09. It shows where their outputs meet the model boundary: split the hardcoded `SYSTEM` into independent sections, assemble them from real runtime state, and cache the result.
-四个 section,两种加载策略:
+Four sections, two loading strategies:
-| Section | 加载策略 | 内容 | 判断依据 |
-|---------|---------|------|---------|
-| identity | 始终 | 你是谁、怎么做事 | 始终存在 |
-| tools | 始终 | 可用工具列表 | `enabled_tools` |
-| workspace | 始终 | 工作目录 | 始终存在 |
-| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |
+| Section | Strategy | Content | Condition |
+|---------|----------|---------|-----------|
+| identity | always | who you are, how to work | always present |
+| tools | always | available tool list | `enabled_tools` |
+| workspace | always | working directory | always present |
+| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |
-关键设计:section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。
+Key design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages.
---
-## 工作原理
+## How It Works
-### PROMPT_SECTIONS: 分段定义
+### PROMPT_SECTIONS: Topic-Keyed Fragments
-把一大段字符串拆成字典,每个 key 是一个主题:
+Split the monolithic string into a dictionary, each key is a topic:
```python
PROMPT_SECTIONS = {
@@ -71,26 +71,26 @@ PROMPT_SECTIONS = {
}
```
-每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。
+Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.
-### assemble_system_prompt: 按需拼接
+### assemble_system_prompt: On-Demand Assembly
-不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:
+Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:
```python
def assemble_system_prompt(context: dict) -> str:
sections = []
- # 始终加载
+ # Always loaded
sections.append(PROMPT_SECTIONS["identity"])
- # 从 context 动态获取 tools 和 workspace
+ # Dynamic — tools and workspace from context
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
- sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
+ sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
- # 按需加载 — 基于真实状态,不是关键词
+ # On-demand — based on real state, not keywords
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
@@ -98,13 +98,13 @@ def assemble_system_prompt(context: dict) -> str:
return "\n\n".join(sections)
```
-"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。
+"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions.
-为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。
+Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).
-### get_system_prompt: 缓存避免重复拼接
+### get_system_prompt: Cache to Avoid Re-Assembly
-上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:
+When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:
```python
def get_system_prompt(context: dict) -> str:
@@ -117,13 +117,13 @@ def get_system_prompt(context: dict) -> str:
return _last_prompt
```
-用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。
+`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.
-注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。
+Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.
-### context: 真实状态,不是关键词猜测
+### context: Real State, Not Keyword Guessing
-context 反映当前运行态的真实状态:
+Context reflects the actual runtime state:
```python
def update_context(context: dict, messages: list) -> dict:
@@ -139,9 +139,9 @@ def update_context(context: dict, messages: list) -> dict:
}
```
-`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。
+`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages.
-### 合起来跑
+### Putting It Together
```python
def agent_loop(messages: list, context: dict):
@@ -150,106 +150,54 @@ def agent_loop(messages: list, context: dict):
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
- # ... 工具执行 ...
+ # ... tool execution ...
context = update_context(context, messages)
system = get_system_prompt(context)
```
-每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。
+At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.
---
-## 相对 s09 的变更
+## Changes From s09
-| 组件 | 之前 (s09) | 之后 (s10) |
-|------|-----------|-----------|
-| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |
-| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) |
-| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |
-| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
-| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) |
+| Component | Before (s09) | After (s10) |
+|-----------|-------------|-------------|
+| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |
+| caching | None | get_system_prompt (json.dumps detection + cache) |
+| new functions | — | assemble_system_prompt, get_system_prompt, update_context |
+| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
+| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s10_system_prompt/code.py
```
-观察重点:
+Safety note: this focused teaching script still executes the model's `bash` string with `shell=True` and omits s03's permission gate. Run it only in a disposable workspace; production harnesses must restore permission and sandbox boundaries.
-1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)
-2. 连续对话时,缓存命中显示 `[cache hit]`
-3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载
+What to watch for:
-试试这些 prompt:
+1. Output shows which sections were loaded (`[assembled] sections: ...` label)
+2. Cache hits show `[cache hit]` during continued conversation
+3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn
-1. `Read the file README.md`(观察始终加载的三个 section)
-2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引)
-3. `Read the file code.py`(观察 memory section 是否出现)
+Try these prompts:
+
+1. `Read the file README.md` (observe the three always-loaded sections)
+2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index)
+3. `Read the file code.py` (observe whether the memory section appears)
---
-## 接下来
+## What's Next
-System prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。
+The model input can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal.
-s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。
-
-
-深入 CC 源码
-
-> 以下基于 CC 源码 `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` 的分析。
-
-### CC 的 system prompt 有多少 section?
-
-数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类:
-
-**静态 section**(始终加载):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。
-
-**动态 section**(按状态加载):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。
-
-`mcp_instructions` 是唯一的易失性 section(通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。
-
-### 组装函数
-
-```typescript
-getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise
-```
-
-返回 `string[]`(每个元素是一个 section),由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。
-
-### cache scope
-
-启用 global cache boundary 时,静态 section 合并成一个 global cache block,动态 section 不使用 global cache(`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。
-
-教学版的缓存只避免重复拼接字符串。CC 的三层缓存:
-
-1. **lodash memoize**:`getSystemContext` 和 `getUserContext` 在会话中缓存(`context.ts`)
-2. **section 注册缓存**:`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear` 或 `/compact` 时清除
-3. **API 级缓存**:`splitSysPromptPrefix()`(`api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块
-
-### getUserContext vs getSystemContext
-
-| | getSystemContext | getUserContext |
-|---|---|---|
-| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |
-| 注入方式 | 追加到 system prompt 数组 | 前置为 `` 用户消息 |
-| 何时跳过 | 自定义 system prompt 时 | 始终运行 |
-
-### 模式如何改变 prompt
-
-- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行
-- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section
-- **Coordinator**:用协调器专用 prompt 完全替换
-- **Agent 模式**:Agent 定义的 prompt 替换或追加到默认 prompt
-
-### 总大小
-
-标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文(CLAUDE.md)和系统上下文(git status)在此基础上累加。
-
-
+s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.
diff --git a/s10_system_prompt/README.zh.md b/s10_system_prompt/README.zh.md
new file mode 100644
index 00000000..0f6519ba
--- /dev/null
+++ b/s10_system_prompt/README.zh.md
@@ -0,0 +1,203 @@
+# s10: Context Assembly — 在运行时组装模型输入
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
+> *"模型输入是组装出来的,不是写死的"* — 稳定分段 + 运行时状态 + 缓存。
+>
+> **Harness 层**: 上下文组装 — 把稳定指令和动态状态组成模型输入。
+
+---
+
+## 问题
+
+从 s01 到 s09,system prompt 都是一行硬编码:
+
+```python
+SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
+```
+
+s01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:
+
+```python
+SYSTEM = (
+ f"You are a coding agent at {WORKDIR}. "
+ "Use tools to solve tasks. Act, don't explain. "
+ "Before starting any multi-step task, use todo_write. "
+ "Skills are available via list_skills and load_skill. "
+ "Relevant memories are injected below when available. "
+ # ... 加一个能力就多一段
+)
+```
+
+三个问题:
+
+1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留
+2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突
+3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token
+
+System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。
+
+---
+
+## 解决方案
+
+
+
+s10 是上下文管理与错误恢复之间的一节短桥接课。它不增加新的存储系统,也不把 s08 与 s09 合并;它只展示两者的输出如何在模型边界汇合:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,并缓存结果。
+
+四个 section,两种加载策略:
+
+| Section | 加载策略 | 内容 | 判断依据 |
+|---------|---------|------|---------|
+| identity | 始终 | 你是谁、怎么做事 | 始终存在 |
+| tools | 始终 | 可用工具列表 | `enabled_tools` |
+| workspace | 始终 | 工作目录 | 始终存在 |
+| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |
+
+关键设计:section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。
+
+---
+
+## 工作原理
+
+### PROMPT_SECTIONS: 分段定义
+
+把一大段字符串拆成字典,每个 key 是一个主题:
+
+```python
+PROMPT_SECTIONS = {
+ "identity": "You are a coding agent. Act, don't explain.",
+}
+```
+
+每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。
+
+### assemble_system_prompt: 按需拼接
+
+不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:
+
+```python
+def assemble_system_prompt(context: dict) -> str:
+ sections = []
+
+ # 始终加载
+ sections.append(PROMPT_SECTIONS["identity"])
+
+ # 从 context 动态获取 tools 和 workspace
+ tools = ", ".join(context.get("enabled_tools", []))
+ if tools:
+ sections.append(f"Available tools: {tools}.")
+ sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
+
+ # 按需加载 — 基于真实状态,不是关键词
+ memories = context.get("memories", "")
+ if memories:
+ sections.append(f"Relevant memories:\n{memories}")
+
+ return "\n\n".join(sections)
+```
+
+"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。
+
+为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。
+
+### get_system_prompt: 缓存避免重复拼接
+
+上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:
+
+```python
+def get_system_prompt(context: dict) -> str:
+ global _last_context_key, _last_prompt
+ key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
+ if key == _last_context_key and _last_prompt:
+ return _last_prompt
+ _last_context_key = key
+ _last_prompt = assemble_system_prompt(context)
+ return _last_prompt
+```
+
+用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。
+
+注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。
+
+### context: 真实状态,不是关键词猜测
+
+context 反映当前运行态的真实状态:
+
+```python
+def update_context(context: dict, messages: list) -> dict:
+ memories = ""
+ if MEMORY_INDEX.exists():
+ content = MEMORY_INDEX.read_text().strip()
+ if content:
+ memories = content
+ return {
+ "enabled_tools": list(TOOL_HANDLERS.keys()),
+ "workspace": str(WORKDIR),
+ "memories": memories,
+ }
+```
+
+`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。
+
+### 合起来跑
+
+```python
+def agent_loop(messages: list, context: dict):
+ system = get_system_prompt(context)
+ while True:
+ response = client.messages.create(
+ model=MODEL, system=system, messages=messages,
+ tools=TOOLS, max_tokens=8000)
+ # ... 工具执行 ...
+ context = update_context(context, messages)
+ system = get_system_prompt(context)
+```
+
+每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。
+
+---
+
+## 相对 s09 的变更
+
+| 组件 | 之前 (s09) | 之后 (s10) |
+|------|-----------|-----------|
+| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |
+| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) |
+| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |
+| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
+| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s10_system_prompt/code.py
+```
+
+安全说明:这份聚焦教学脚本仍用 `shell=True` 执行模型给出的 `bash` 字符串,并省略了 s03 的权限闸门。只在可丢弃工作区运行;生产 harness 必须恢复权限与沙箱边界。
+
+观察重点:
+
+1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)
+2. 连续对话时,缓存命中显示 `[cache hit]`
+3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载
+
+试试这些 prompt:
+
+1. `Read the file README.md`(观察始终加载的三个 section)
+2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引)
+3. `Read the file code.py`(观察 memory section 是否出现)
+
+---
+
+## 接下来
+
+模型输入可以在运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。
+
+s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。
+
+
diff --git a/s10_system_prompt/code.py b/s10_system_prompt/code.py
index be1d5f3d..3dba1c4d 100644
--- a/s10_system_prompt/code.py
+++ b/s10_system_prompt/code.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-s10: System Prompt — Runtime prompt assembly with caching.
+s10: Context Assembly — Runtime model-input assembly with caching.
Run: python s10_system_prompt/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
@@ -55,7 +55,7 @@ def assemble_system_prompt(context: dict) -> str:
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
- sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
+ sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# Conditional — memory loaded when MEMORY.md exists and has content
memories = context.get("memories", "")
@@ -199,7 +199,7 @@ def agent_loop(messages: list, context: dict):
if __name__ == "__main__":
- print("s10: system prompt — runtime assembly")
+ print("s10: context assembly — runtime model input")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
context = update_context({}, [])
diff --git a/s11_error_recovery/README.en.md b/s11_error_recovery/README.en.md
deleted file mode 100644
index 070397b0..00000000
--- a/s11_error_recovery/README.en.md
+++ /dev/null
@@ -1,277 +0,0 @@
-# s11: Error Recovery — Errors aren't the end, they're the start of a retry
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20
-> *"Errors aren't the end, they're the start of a retry"* — escalate tokens, compact context, switch models.
->
-> **Harness layer**: Resilience — classify and recover when the main loop hits errors.
-
----
-
-## The Problem
-
-The Agent is running along and then errors out:
-
-```
-Error: 529 overloaded
-```
-
-The Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context — it just crashes.
-
-In production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch.
-
----
-
-## Solution
-
-
-
-The loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again.
-
-The three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. CC actually has 13+ reason codes; see the Deep Dive for the rest):
-
-| Pattern | Trigger | Recovery Action |
-|----------|---------|-----------------|
-| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt |
-| Context overflow | `prompt_too_long` | Reactive compact → retry |
-| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 |
-
----
-
-## How It Works
-
-### Path 1: Output Truncated
-
-The model runs out of tokens mid-sentence — `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response.
-
-On the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request — the truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times:
-
-```python
-if response.stop_reason == "max_tokens":
- # First escalation: don't append truncated output, retry same request
- if not state.has_escalated:
- max_tokens = ESCALATED_MAX_TOKENS
- state.has_escalated = True
- continue # messages unchanged, same request with more tokens
- # 64K still truncated: save output + continuation prompt
- messages.append({"role": "assistant", "content": response.content})
- if state.recovery_count < MAX_RECOVERY_RETRIES:
- messages.append({"role": "user", "content":
- "Output token limit hit. Resume directly — "
- "no apology, no recap. Pick up mid-thought."})
- state.recovery_count += 1
- continue
- return # still truncated after 3 continuations
-# Normal: append after max_tokens check
-messages.append({"role": "assistant", "content": response.content})
-```
-
-Escalation gets one chance; continuation gets up to 3. After that, exit — further continuations won't produce meaningful output.
-
-### Path 2: Context Overflow
-
-The LLM says "your context is too long" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit.
-
-Trigger reactive compact — more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real CC generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit — compacting again won't make it any smaller:
-
-```python
-except PromptTooLongError:
- if not state.has_attempted_reactive_compact:
- messages[:] = reactive_compact(messages)
- state.has_attempted_reactive_compact = True
- continue
- return # Already compacted and still over limit — must exit
-```
-
-### Path 3: Transient Failures
-
-Network blips, 429 rate limiting, 529 overload — these aren't bugs, they're normal in distributed systems.
-
-Both 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured):
-
-```python
-def retry_delay(attempt, retry_after=None):
- if retry_after:
- return retry_after
- base = min(500 * (2 ** attempt), 32000) / 1000
- return base + random.uniform(0, base * 0.25)
-
-def with_retry(fn, state, max_retries=10):
- for attempt in range(max_retries):
- try:
- return fn()
- except (RateLimitError, OverloadedError):
- delay = retry_delay(attempt)
- time.sleep(delay)
- if is_overloaded:
- state.consecutive_529 += 1
- if state.consecutive_529 >= 3 and FALLBACK_MODEL:
- state.current_model = FALLBACK_MODEL
- raise MaxRetriesExceeded()
-```
-
-Backoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority.
-
-### Putting It All Together
-
-```python
-def agent_loop(messages, context):
- system = get_system_prompt(context)
- state = RecoveryState()
- max_tokens = 8000
-
- while True:
- try:
- response = with_retry(
- lambda: client.messages.create(
- model=state.current_model, system=system,
- messages=messages, tools=TOOLS,
- max_tokens=max_tokens),
- state)
- except Exception as e:
- if is_prompt_too_long_error(e):
- if not state.has_attempted_reactive_compact:
- messages[:] = reactive_compact(messages)
- state.has_attempted_reactive_compact = True
- continue
- return
- log_error(e)
- return
-
- # max_tokens check BEFORE appending to messages
- if response.stop_reason == "max_tokens":
- if not state.has_escalated:
- max_tokens = 64000
- state.has_escalated = True
- continue # retry same request, messages unchanged
- # save truncated output + continuation prompt
- messages.append({"role": "assistant", "content": response.content})
- messages.append({"role": "user", "content": CONTINUATION_PROMPT})
- continue
- # Normal completion
- messages.append({"role": "assistant", "content": response.content})
-
- if response.stop_reason != "tool_use":
- return
- # ... tool execution ...
-```
-
-The outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type.
-
----
-
-## Changes from s10
-
-| Component | Before (s10) | After (s11) |
-|-----------|-------------|-------------|
-| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff |
-| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |
-| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |
-| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
-| Loop | Bare LLM call | Wrapped in try/except + continue retry |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s11_error_recovery/code.py
-```
-
-Try these prompts:
-
-1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log)
-2. Read many files consecutively to bloat the context, and observe reactive compact
-3. If you encounter 429/529, observe the exponential backoff log output
-
----
-
-## What's Next
-
-The Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done.
-
-What if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system.
-
-s12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.
-
-
-Deep Dive into CC Source
-
-> The following is based on CC source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines).
-
-### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3)
-
-The teaching version covers 3 of the most common recovery patterns. CC actually has a dozen-plus reason/transition codes, evaluated after every LLM call:
-
-| Reason/Transition | Teaching Version | CC Behavior |
-|---|---|---|
-| `completed` | Normal completion | Return result |
-| `next_turn` | Normal tool call | Continue to next tool execution round |
-| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation |
-| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) |
-| `reactive_compact_retry` | Path 2 | Reactive compact → retry |
-| `prompt_too_long` | Path 2 | Same as above |
-| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first |
-| `model_error` | Not covered | Retry |
-| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically |
-| `aborted_streaming` | Not covered | Streaming abort recovery |
-| `aborted_tools` | Not covered | Tool abort |
-| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects |
-| `stop_hook_prevented` | Not covered | Hooks prevent execution |
-| `hook_stopped` | Not covered | Hook stopped execution |
-| `token_budget_continuation` | Not covered | Continue when token usage < 90% |
-| `blocking_limit` | Not covered | Blocking limit reached |
-| `max_turns` | Not covered | Maximum turns reached |
-
-The teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic.
-
-### 2. Precise Exponential Backoff Formula
-
-CC's backoff delay (`withRetry.ts:530-548`):
-
-```
-delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
-```
-
-| Attempt | Base Delay | + Jitter |
-|---------|-----------|----------|
-| 1 | 500ms | 0-125ms |
-| 2 | 1000ms | 0-250ms |
-| 4 | 4000ms | 0-1000ms |
-| 7+ | 32000ms (cap) | 0-8000ms |
-
-If the server returns a `Retry-After` header, that value takes priority.
-
-### 3. Original CONTINUATION Prompt
-
-CC's continuation prompt (`query.ts:1225-1227`):
-
-```
-Output token limit hit. Resume directly — no apology, no recap of what
-you were doing. Pick up mid-thought if that is where the cut happened.
-Break remaining work into smaller pieces.
-```
-
-Token budget nudge prompt (`tokenBudget.ts:72`):
-
-```
-Stopped at {pct}% of token target. Keep working — do not summarize.
-```
-
-### 4. Streaming Error Handling
-
-In CC's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed.
-
-### 5. 529 → Fallback Model Switch
-
-After 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), CC automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees "Switched to {model} due to high demand".
-
-### 6. Diminishing Returns Detection
-
-Token budget "continuations" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines "continuing won't produce meaningful output" and stops continuation (`tokenBudget.ts:60-62`).
-
-
-
-
diff --git a/s11_error_recovery/README.ja.md b/s11_error_recovery/README.ja.md
index 27c5e597..cde9d586 100644
--- a/s11_error_recovery/README.ja.md
+++ b/s11_error_recovery/README.ja.md
@@ -1,8 +1,8 @@
# s11: Error Recovery — エラーは終わりではなく、リトライの始まり
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20
+s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
> *"エラーは終わりではなく、リトライの始まり"* — トークン拡張、コンテキスト圧縮、モデル切り替え。
>
> **Harness 層**: 耐障害性 — メインループのエラーを分類し復旧。
diff --git a/s11_error_recovery/README.md b/s11_error_recovery/README.md
index 32ae6312..d216b3fc 100644
--- a/s11_error_recovery/README.md
+++ b/s11_error_recovery/README.md
@@ -1,51 +1,51 @@
-# s11: Error Recovery — 错误不是结束,是重试的开始
+# s11: Error Recovery — Errors aren't the end, they're the start of a retry
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20
-> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。
+s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
+> *"Errors aren't the end, they're the start of a retry"* — escalate tokens, compact context, switch models.
>
-> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。
+> **Harness layer**: Resilience — classify and recover when the main loop hits errors.
---
-## 问题
+## The Problem
-Agent 跑着跑着报错了:
+The Agent is running along and then errors out:
```
Error: 529 overloaded
```
-Agent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。
+The Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context — it just crashes.
-生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型话说一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。
+In production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch.
---
-## 解决方案
+## Solution
-
+
-s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。
+The loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again.
-三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。CC 实际有 13+ reason code,其余见 Deep dive):
+The three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. CC actually has 13+ reason codes; see the Deep Dive for the rest):
-| 模式 | 触发 | 恢复动作 |
-|------|------|---------|
-| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |
-| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |
-| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |
+| Pattern | Trigger | Recovery Action |
+|----------|---------|-----------------|
+| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt |
+| Context overflow | `prompt_too_long` | Reactive compact → retry |
+| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 |
---
-## 工作原理
+## How It Works
-### 路径 1: 输出被截断
+### Path 1: Output Truncated
-模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。
+The model runs out of tokens mid-sentence — `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response.
-第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次:
+On the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request — the truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times:
```python
if response.stop_reason == "max_tokens":
@@ -67,13 +67,13 @@ if response.stop_reason == "max_tokens":
messages.append({"role": "assistant", "content": response.content})
```
-升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。
+Escalation gets one chance; continuation gets up to 3. After that, exit — further continuations won't produce meaningful output.
-### 路径 2: 上下文超限
+### Path 2: Context Overflow
-LLM 说"你的上下文太长了"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。
+The LLM says "your context is too long" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit.
-触发 reactive compact——比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出——再压缩也不会变小:
+Trigger reactive compact — more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real CC generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit — compacting again won't make it any smaller:
```python
except PromptTooLongError:
@@ -81,14 +81,14 @@ except PromptTooLongError:
messages[:] = reactive_compact(messages)
state.has_attempted_reactive_compact = True
continue
- return # 压缩过了还是超限,只能退出
+ return # Already compacted and still over limit — must exit
```
-### 路径 3: 临时故障
+### Path 3: Transient Failures
-网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。
+Network blips, 429 rate limiting, 529 overload — these aren't bugs, they're normal in distributed systems.
-429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):
+Both 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured):
```python
def retry_delay(attempt, retry_after=None):
@@ -111,9 +111,9 @@ def with_retry(fn, state, max_retries=10):
raise MaxRetriesExceeded()
```
-退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。
+Backoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority.
-### 合起来跑
+### Putting It All Together
```python
def agent_loop(messages, context):
@@ -157,96 +157,96 @@ def agent_loop(messages, context):
# ... tool execution ...
```
-外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。
+The outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type.
---
-## 相对 s10 的变更
+## Changes from s10
-| 组件 | 之前 (s10) | 之后 (s11) |
-|------|-----------|-----------|
-| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |
-| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |
-| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |
-| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
-| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |
+| Component | Before (s10) | After (s11) |
+|-----------|-------------|-------------|
+| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff |
+| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |
+| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |
+| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
+| Loop | Bare LLM call | Wrapped in try/except + continue retry |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s11_error_recovery/code.py
```
-试试这些 prompt:
+Try these prompts:
-1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)
-2. 连续读取大量文件撑大上下文,观察 reactive compact
-3. 如果遇到 429/529,观察指数退避的日志输出
+1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log)
+2. Read many files consecutively to bloat the context, and observe reactive compact
+3. If you encounter 429/529, observe the exponential backoff log output
---
-## 接下来
+## What's Next
-Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一次性"的——你给它一个任务,它做完,结束。
+The Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done.
-能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。
+What if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system.
-s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。
+s12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。
+> The following is based on CC source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines).
-### 一、十几种 reason/transition(不只是 3 条)
+### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3)
-教学版讲了 3 种最常见的恢复模式。CC 实际有十几种 reason/transition,每轮 LLM 调用后都会判断:
+The teaching version covers 3 of the most common recovery patterns. CC actually has a dozen-plus reason/transition codes, evaluated after every LLM call:
-| reason/transition | 教学版对应 | CC 行为 |
+| Reason/Transition | Teaching Version | CC Behavior |
|---|---|---|
-| `completed` | 正常完成 | 返回结果 |
-| `next_turn` | 正常工具调用 | 继续下一轮工具执行 |
-| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 |
-| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) |
-| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 |
-| `prompt_too_long` | 路径 2 | 同上 |
-| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 |
-| `model_error` | 未展开 | 重试 |
-| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 |
-| `aborted_streaming` | 未展开 | 流式中止恢复 |
-| `aborted_tools` | 未展开 | 工具中止 |
-| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 |
-| `stop_hook_prevented` | 未展开 | hooks 阻止 |
-| `hook_stopped` | 未展开 | hook 停止执行 |
-| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 |
-| `blocking_limit` | 未展开 | 阻塞限制 |
-| `max_turns` | 未展开 | 达到最大轮次 |
+| `completed` | Normal completion | Return result |
+| `next_turn` | Normal tool call | Continue to next tool execution round |
+| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation |
+| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) |
+| `reactive_compact_retry` | Path 2 | Reactive compact → retry |
+| `prompt_too_long` | Path 2 | Same as above |
+| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first |
+| `model_error` | Not covered | Retry |
+| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically |
+| `aborted_streaming` | Not covered | Streaming abort recovery |
+| `aborted_tools` | Not covered | Tool abort |
+| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects |
+| `stop_hook_prevented` | Not covered | Hooks prevent execution |
+| `hook_stopped` | Not covered | Hook stopped execution |
+| `token_budget_continuation` | Not covered | Continue when token usage < 90% |
+| `blocking_limit` | Not covered | Blocking limit reached |
+| `max_turns` | Not covered | Maximum turns reached |
-教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。
+The teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic.
-### 二、指数退避的精确公式
+### 2. Precise Exponential Backoff Formula
-CC 的退避延迟(`withRetry.ts:530-548`):
+CC's backoff delay (`withRetry.ts:530-548`):
```
delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
```
-| 尝试 | 基础延迟 | + 抖动 |
-|------|---------|--------|
+| Attempt | Base Delay | + Jitter |
+|---------|-----------|----------|
| 1 | 500ms | 0-125ms |
| 2 | 1000ms | 0-250ms |
| 4 | 4000ms | 0-1000ms |
-| 7+ | 32000ms(上限) | 0-8000ms |
+| 7+ | 32000ms (cap) | 0-8000ms |
-如果服务器返回 `Retry-After` header,优先用那个值。
+If the server returns a `Retry-After` header, that value takes priority.
-### 三、CONTINUATION 提示原文
+### 3. Original CONTINUATION Prompt
-CC 的续写提示(`query.ts:1225-1227`):
+CC's continuation prompt (`query.ts:1225-1227`):
```
Output token limit hit. Resume directly — no apology, no recap of what
@@ -254,23 +254,23 @@ you were doing. Pick up mid-thought if that is where the cut happened.
Break remaining work into smaller pieces.
```
-Token budget 的 nudge 提示(`tokenBudget.ts:72`):
+Token budget nudge prompt (`tokenBudget.ts:72`):
```
Stopped at {pct}% of token target. Keep working — do not summarize.
```
-### 四、流式错误处理
+### 4. Streaming Error Handling
-CC 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。
+In CC's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed.
-### 五、529 → Fallback Model 切换
+### 5. 529 → Fallback Model Switch
-连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),CC 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 "Switched to {model} due to high demand"。
+After 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), CC automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees "Switched to {model} due to high demand".
-### 六、Diminishing Returns 检测
+### 6. Diminishing Returns Detection
-Token budget 的"继续"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断"继续也没有实质性产出",停止 continuation(`tokenBudget.ts:60-62`)。
+Token budget "continuations" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines "continuing won't produce meaningful output" and stops continuation (`tokenBudget.ts:60-62`).
diff --git a/s11_error_recovery/README.zh.md b/s11_error_recovery/README.zh.md
new file mode 100644
index 00000000..7898cc95
--- /dev/null
+++ b/s11_error_recovery/README.zh.md
@@ -0,0 +1,277 @@
+# s11: Error Recovery — 错误不是结束,是重试的开始
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
+> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。
+>
+> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。
+
+---
+
+## 问题
+
+Agent 跑着跑着报错了:
+
+```
+Error: 529 overloaded
+```
+
+Agent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。
+
+生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型话说一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。
+
+---
+
+## 解决方案
+
+
+
+s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。
+
+三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。CC 实际有 13+ reason code,其余见 Deep dive):
+
+| 模式 | 触发 | 恢复动作 |
+|------|------|---------|
+| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |
+| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |
+| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |
+
+---
+
+## 工作原理
+
+### 路径 1: 输出被截断
+
+模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。
+
+第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次:
+
+```python
+if response.stop_reason == "max_tokens":
+ # First escalation: don't append truncated output, retry same request
+ if not state.has_escalated:
+ max_tokens = ESCALATED_MAX_TOKENS
+ state.has_escalated = True
+ continue # messages unchanged, same request with more tokens
+ # 64K still truncated: save output + continuation prompt
+ messages.append({"role": "assistant", "content": response.content})
+ if state.recovery_count < MAX_RECOVERY_RETRIES:
+ messages.append({"role": "user", "content":
+ "Output token limit hit. Resume directly — "
+ "no apology, no recap. Pick up mid-thought."})
+ state.recovery_count += 1
+ continue
+ return # still truncated after 3 continuations
+# Normal: append after max_tokens check
+messages.append({"role": "assistant", "content": response.content})
+```
+
+升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。
+
+### 路径 2: 上下文超限
+
+LLM 说"你的上下文太长了"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。
+
+触发 reactive compact——比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出——再压缩也不会变小:
+
+```python
+except PromptTooLongError:
+ if not state.has_attempted_reactive_compact:
+ messages[:] = reactive_compact(messages)
+ state.has_attempted_reactive_compact = True
+ continue
+ return # 压缩过了还是超限,只能退出
+```
+
+### 路径 3: 临时故障
+
+网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。
+
+429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):
+
+```python
+def retry_delay(attempt, retry_after=None):
+ if retry_after:
+ return retry_after
+ base = min(500 * (2 ** attempt), 32000) / 1000
+ return base + random.uniform(0, base * 0.25)
+
+def with_retry(fn, state, max_retries=10):
+ for attempt in range(max_retries):
+ try:
+ return fn()
+ except (RateLimitError, OverloadedError):
+ delay = retry_delay(attempt)
+ time.sleep(delay)
+ if is_overloaded:
+ state.consecutive_529 += 1
+ if state.consecutive_529 >= 3 and FALLBACK_MODEL:
+ state.current_model = FALLBACK_MODEL
+ raise MaxRetriesExceeded()
+```
+
+退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。
+
+### 合起来跑
+
+```python
+def agent_loop(messages, context):
+ system = get_system_prompt(context)
+ state = RecoveryState()
+ max_tokens = 8000
+
+ while True:
+ try:
+ response = with_retry(
+ lambda: client.messages.create(
+ model=state.current_model, system=system,
+ messages=messages, tools=TOOLS,
+ max_tokens=max_tokens),
+ state)
+ except Exception as e:
+ if is_prompt_too_long_error(e):
+ if not state.has_attempted_reactive_compact:
+ messages[:] = reactive_compact(messages)
+ state.has_attempted_reactive_compact = True
+ continue
+ return
+ log_error(e)
+ return
+
+ # max_tokens check BEFORE appending to messages
+ if response.stop_reason == "max_tokens":
+ if not state.has_escalated:
+ max_tokens = 64000
+ state.has_escalated = True
+ continue # retry same request, messages unchanged
+ # save truncated output + continuation prompt
+ messages.append({"role": "assistant", "content": response.content})
+ messages.append({"role": "user", "content": CONTINUATION_PROMPT})
+ continue
+ # Normal completion
+ messages.append({"role": "assistant", "content": response.content})
+
+ if response.stop_reason != "tool_use":
+ return
+ # ... tool execution ...
+```
+
+外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。
+
+---
+
+## 相对 s10 的变更
+
+| 组件 | 之前 (s10) | 之后 (s11) |
+|------|-----------|-----------|
+| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |
+| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |
+| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |
+| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
+| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s11_error_recovery/code.py
+```
+
+试试这些 prompt:
+
+1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)
+2. 连续读取大量文件撑大上下文,观察 reactive compact
+3. 如果遇到 429/529,观察指数退避的日志输出
+
+---
+
+## 接下来
+
+Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一次性"的——你给它一个任务,它做完,结束。
+
+能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。
+
+s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。
+
+### 一、十几种 reason/transition(不只是 3 条)
+
+教学版讲了 3 种最常见的恢复模式。CC 实际有十几种 reason/transition,每轮 LLM 调用后都会判断:
+
+| reason/transition | 教学版对应 | CC 行为 |
+|---|---|---|
+| `completed` | 正常完成 | 返回结果 |
+| `next_turn` | 正常工具调用 | 继续下一轮工具执行 |
+| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 |
+| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) |
+| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 |
+| `prompt_too_long` | 路径 2 | 同上 |
+| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 |
+| `model_error` | 未展开 | 重试 |
+| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 |
+| `aborted_streaming` | 未展开 | 流式中止恢复 |
+| `aborted_tools` | 未展开 | 工具中止 |
+| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 |
+| `stop_hook_prevented` | 未展开 | hooks 阻止 |
+| `hook_stopped` | 未展开 | hook 停止执行 |
+| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 |
+| `blocking_limit` | 未展开 | 阻塞限制 |
+| `max_turns` | 未展开 | 达到最大轮次 |
+
+教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。
+
+### 二、指数退避的精确公式
+
+CC 的退避延迟(`withRetry.ts:530-548`):
+
+```
+delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
+```
+
+| 尝试 | 基础延迟 | + 抖动 |
+|------|---------|--------|
+| 1 | 500ms | 0-125ms |
+| 2 | 1000ms | 0-250ms |
+| 4 | 4000ms | 0-1000ms |
+| 7+ | 32000ms(上限) | 0-8000ms |
+
+如果服务器返回 `Retry-After` header,优先用那个值。
+
+### 三、CONTINUATION 提示原文
+
+CC 的续写提示(`query.ts:1225-1227`):
+
+```
+Output token limit hit. Resume directly — no apology, no recap of what
+you were doing. Pick up mid-thought if that is where the cut happened.
+Break remaining work into smaller pieces.
+```
+
+Token budget 的 nudge 提示(`tokenBudget.ts:72`):
+
+```
+Stopped at {pct}% of token target. Keep working — do not summarize.
+```
+
+### 四、流式错误处理
+
+CC 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。
+
+### 五、529 → Fallback Model 切换
+
+连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),CC 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 "Switched to {model} due to high demand"。
+
+### 六、Diminishing Returns 检测
+
+Token budget 的"继续"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断"继续也没有实质性产出",停止 continuation(`tokenBudget.ts:60-62`)。
+
+
+
+
diff --git a/s12_task_system/README.en.md b/s12_task_system/README.en.md
deleted file mode 100644
index 756aedb4..00000000
--- a/s12_task_system/README.en.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# s12: Task System — Break Big Goals into Small Tasks
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20
-
-> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the foundation for multi-agent collaboration.
->
-> **Harness Layer**: Tasks — Persisted goals, recoverable progress.
-
----
-
-## The Problem
-
-The agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again...
-
-You can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection.
-
-s05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk.
-
----
-
-## The Solution
-
-
-
-Teaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 new task tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in CC source, `utils/tasks.ts` only handles CRUD, while `query.ts`'s with_retry/RecoveryState handles error recovery, with no coupling between them.
-
-TodoWrite vs Task System:
-
-| | TodoWrite (s05) | Task System (s12) |
-|---|---|---|
-| Role | Execution checklist for the current task | Recoverable task system |
-| Storage | In-process / session state | `.tasks/{id}.json` |
-| Dependencies | None | `blockedBy` / `blocks` graph |
-| Lifecycle | Current session / current task | Cross-session |
-| Coordination | No task claiming | `owner` / claim |
-| Status | pending / in_progress / completed | pending / in_progress / completed |
-| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |
-
----
-
-## How It Works
-
-
-
-### Task: Data Structure
-
-Each task is a JSON file, stored in the `.tasks/` directory:
-
-```python
-@dataclass
-class Task:
- id: str
- subject: str
- description: str
- status: str # pending | in_progress | completed
- owner: str | None # Agent name (multi-agent scenarios)
- blockedBy: list[str] # List of dependency task IDs
-```
-
-IDs are generated with `timestamp + random hex`, simple but sufficient. CC uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design.
-
-### create_task: Create Tasks
-
-```python
-def create_task(subject: str, description: str = "",
- blockedBy: list[str] | None = None) -> Task:
- task = Task(
- id=f"task_{int(time.time())}_{random_hex(4)}",
- subject=subject, description=description,
- status="pending", owner=None,
- blockedBy=blockedBy or [],
- )
- save_task(task)
- return task
-```
-
-Automatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example "write API" has `blockedBy: ["task_schema"]`.
-
-### can_start: Dependency Check
-
-A task can only start after all its `blockedBy` dependencies are **completed**:
-
-```python
-def can_start(task_id: str) -> bool:
- task = load_task(task_id)
- for dep_id in task.blockedBy:
- if not _task_path(dep_id).exists():
- return False # missing dependency = blocked
- dep = load_task(dep_id)
- if dep.status != "completed":
- return False
- return True
-```
-
-`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs.
-
-### claim_task: Claim a Task
-
-When the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios:
-
-```python
-def claim_task(task_id: str, owner: str = "agent") -> str:
- task = load_task(task_id)
- if task.status != "pending":
- return f"Task {task_id} is {task.status}, cannot claim"
- if not can_start(task_id):
- deps = [d for d in task.blockedBy
- if load_task(d).status != "completed"]
- return f"Blocked by: {deps}"
- task.owner = owner
- task.status = "in_progress"
- save_task(task)
- return f"Claimed {task_id} ({task.subject})"
-```
-
-If the task is already claimed by someone else (`status != "pending"`), or dependencies aren't met (`can_start` returns False), the claim is rejected.
-
-### complete_task: Complete and Unblock
-
-When a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**:
-
-```python
-def complete_task(task_id: str) -> str:
- task = load_task(task_id)
- task.status = "completed"
- save_task(task)
- # Find newly unblocked downstream tasks
- unblocked = [t.subject for t in list_tasks()
- if t.status == "pending" and t.blockedBy
- and can_start(t.id)]
- msg = f"Completed {task_id} ({task.subject})"
- if unblocked:
- msg += f"\nUnblocked: {', '.join(unblocked)}"
- return msg
-```
-
-After completing "schema", `can_start` returns True for "endpoints" and "docs"; they can begin.
-
-### get_task: View Full Details
-
-`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work:
-
-```python
-def get_task(task_id: str) -> str:
- task = load_task(task_id)
- return json.dumps(asdict(task), indent=2)
-```
-
-### State Machine: Two Actions, Three States
-
-```
-pending ──claim──→ in_progress ──complete──→ completed
-```
-
-Here `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states:
-
-- **claim_task**: `pending` → `in_progress`. Sets owner, begins work.
-- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream.
-
-CC has no `in_progress → pending` release path. If a teammate terminates or shuts down, CC unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path.
-
-### Putting It Together
-
-```python
-# Create tasks with dependencies
-schema = create_task("setup database schema")
-endpoints = create_task("create API endpoints", blockedBy=[schema.id])
-tests = create_task("write tests", blockedBy=[endpoints.id])
-docs = create_task("write docs", blockedBy=[schema.id])
-
-# Agent claims the first available task
-claim_task(schema.id) # ✓ Claimed (no dependencies)
-complete_task(schema.id) # ✓ Completed → unblocks endpoints, docs
-
-claim_task(endpoints.id) # ✓ Claimed (schema completed)
-complete_task(endpoints.id) # ✓ Completed → unblocks tests
-
-claim_task(docs.id) # ✓ Claimed (schema completed)
-complete_task(docs.id) # ✓ Completed
-
-claim_task(tests.id) # ✓ Claimed (endpoints completed)
-complete_task(tests.id) # ✓ Completed
-```
-
-Each `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists — the agent reads the files to recover progress.
-
----
-
-## Changes from s11
-
-| Component | Before (s11) | After (s12) |
-|-----------|-------------|-------------|
-| Task management | None | Task dataclass + 5 tools |
-| New types | — | Task (id, subject, description, status, owner, blockedBy) |
-| Storage | No persistence | `.tasks/{id}.json` cross-session |
-| Dependencies | None | `blockedBy` graph + `can_start` check |
-| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |
-| Lifecycle | — | pending → in_progress → completed (no release rollback) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s12_task_system/code.py
-```
-
-Try these prompts:
-
-1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`
-2. `List all tasks and their statuses`
-3. `Claim the first unblocked task and complete it`
-4. `List tasks again — which ones are now unblocked?`
-
-What to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked?
-
----
-
-## What's Next
-
-The task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation.
-
-s13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.
-
-
-Deep Dive into CC Source
-
-> The following is a complete analysis based on CC source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines).
-
-### 1. TaskRecord's Full Fields
-
-The tutorial only covers id, subject, status, owner, blockedBy. CC actually has 9 fields (`utils/tasks.ts:76-89`):
-
-| Field | Type | Purpose |
-|------|------|---------|
-| `id` | string | Incrementing integer ID |
-| `subject` | string | Short title |
-| `description` | string | Free-form description |
-| `activeForm` | string? | Present tense form, shown in spinner when in_progress |
-| `owner` | string? | Assigned agent ID |
-| `status` | pending/in_progress/completed | Lifecycle |
-| `blocks` | string[] | Task IDs blocked by this task (downstream) |
-| `blockedBy` | string[] | Task IDs blocking this task (upstream) |
-| `metadata` | Record? | Arbitrary extension key-value pairs |
-
-Storage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task.
-
-### 2. Not a TodoWrite Upgrade — Two Independent Systems
-
-In CC, Task System and TodoWrite **coexist**, toggled by `isTodoV2Enabled()` (`utils/tasks.ts:133`) — interactive sessions default to Task (V2), non-interactive/SDK sessions default to TodoWrite. The `CLAUDE_CODE_ENABLE_TASKS` env var can force-enable Task. Task has what TodoWrite lacks: file-lock concurrency protection, dependency enforcement, ownership, fs.watch reactive monitoring, lifecycle hooks.
-
-### 3. Concurrent Claim Locking
-
-`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races:
-
-**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock:
-1. Re-read task (prevent TOCTOU)
-2. Check already claimed by another → `already_claimed`
-3. Check already completed → `already_resolved`
-4. Check upstream not completed → `blocked`
-5. Set owner
-
-**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks.
-
-Note: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real CC's `claimTask` primarily resolves owner competition — it only sets owner without changing status. Status updates are handled by `TaskUpdate`.
-
-### 4. High-Water Mark to Prevent ID Reuse
-
-The `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused.
-
-### 5. Four Task Tools
-
-CC's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch).
-
-The teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real CC's `TaskCreate` only accepts subject/description/activeForm/metadata — dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.
-
-
-
-
diff --git a/s12_task_system/README.ja.md b/s12_task_system/README.ja.md
index ebc8c7e0..ac20a05a 100644
--- a/s12_task_system/README.ja.md
+++ b/s12_task_system/README.ja.md
@@ -1,8 +1,8 @@
# s12: Task System — 大きな目標を小さなタスクに分割
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20
+s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
> *"大きな目標を小さなタスクに分け、順序付け、永続化"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。
>
@@ -24,7 +24,7 @@ s05 の TodoWrite は現在のタスクの実行チェックリストで、セ

-教学版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの新規タスクツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤー:CC ソースコードでは `utils/tasks.ts` は CRUD のみ、`query.ts` の with_retry/RecoveryState がエラーリカバリを担当し、互いに非結合。
+教育版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの教育用ツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤーで、タスクモジュールは状態を、query recovery はモデル呼び出し失敗を扱う。
TodoWrite vs Task System:
@@ -37,6 +37,9 @@ TodoWrite vs Task System:
| 分担 | タスク認識を扱わない | `owner` / claim |
| ステータス | pending / in_progress / completed | pending / in_progress / completed |
| 粒度 | Agent 自身の手順 | 認識・追跡・アンロックできるタスク |
+| 更新契約 | リスト全体を置換 | 個別レコードを作成・取得・更新・一覧 |
+
+教育用 API は `create_task`、`list_tasks`、`get_task`、`claim_task`、`complete_task` としてライフサイクルを明示する。Claude Code の製品サーフェスはこれらを `TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList` の 4 ツールにまとめ、認識と完了は独立した公式ツールではなく更新操作として扱う。
---
@@ -248,9 +251,9 @@ s13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は
保存場所:`~/.claude/tasks/{taskListId}/{id}.json`。タスクごとに 1 ファイル。
-### 二、TodoWrite のアップグレードではなく、2 つの独立システム
+### 二、目的は近いが、機構は独立
-CC では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で切り替え(`utils/tasks.ts:133`)— 対話セッションはデフォルトで Task (V2)、非対話/SDK セッションは TodoWrite。環境変数 `CLAUDE_CODE_ENABLE_TASKS` で Task を強制有効化可能。Task は TodoWrite にない機能を持つ:ファイルロック並行保護、依存関係強制、ownership、fs.watch リアクティブ監視、ライフサイクルフック。
+Task ツールと TodoWrite は共存できるが、同じストレージモデルを共有しない。現在の対話型セッションは構造化 Task ツールを既定で使い、TodoWrite は非対話型や Agent SDK などの互換サーフェスに残る。公開範囲はリリースや設定で変わり得る。Task レコードはファイルロック、依存関係、ownership、リアクティブ監視、ライフサイクルフックを追加し、TodoWrite はリスト全体を置換するセッションチェックリストである。
### 三、並行認識のロック機構
diff --git a/s12_task_system/README.md b/s12_task_system/README.md
index 03a92528..439ff298 100644
--- a/s12_task_system/README.md
+++ b/s12_task_system/README.md
@@ -1,52 +1,55 @@
-# s12: Task System — 目标太大,拆成小任务
+# s12: Task System — Break Big Goals into Small Tasks
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20
+s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
-> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。
+> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the foundation for multi-agent collaboration.
>
-> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。
+> **Harness Layer**: Tasks — Persisted goals, recoverable progress.
---
-## 问题
+## The Problem
-Agent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...
+The agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again...
-盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。
+You can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection.
-s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。
+s05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk.
---
-## 解决方案
+## The Solution
-
+
-教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个任务工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:CC 源码中 `utils/tasks.ts` 只管 CRUD,`query.ts` 的 with_retry/RecoveryState 管错误恢复,互不耦合。
+Teaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 teaching tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in CC source, `utils/tasks.ts` handles task state while query recovery handles model-call failures.
-TodoWrite vs Task System:
+TodoWrite vs Task System:
| | TodoWrite (s05) | Task System (s12) |
|---|---|---|
-| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |
-| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |
-| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |
-| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |
-| 分工 | 不负责任务认领 | `owner` / claim |
-| 状态 | pending / in_progress / completed | pending / in_progress / completed |
-| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |
+| Role | Execution checklist for the current task | Recoverable task system |
+| Storage | In-process / session state | `.tasks/{id}.json` |
+| Dependencies | None | `blockedBy` / `blocks` graph |
+| Lifecycle | Current session / current task | Cross-session |
+| Coordination | No task claiming | `owner` / claim |
+| Status | pending / in_progress / completed | pending / in_progress / completed |
+| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |
+| Update contract | Replace the whole checklist | Create/get/update/list individual records |
+
+The teaching API spells the lifecycle out as `create_task`, `list_tasks`, `get_task`, `claim_task`, and `complete_task`. Claude Code's product surface groups those operations into four tools: `TaskCreate`, `TaskGet`, `TaskUpdate`, and `TaskList`; claiming and completion are updates, not separate official tools.
---
-## 工作原理
+## How It Works
-
+
-### Task: 数据结构
+### Task: Data Structure
-每个任务是一个 JSON 文件,存于 `.tasks/` 目录:
+Each task is a JSON file, stored in the `.tasks/` directory:
```python
@dataclass
@@ -55,13 +58,13 @@ class Task:
subject: str
description: str
status: str # pending | in_progress | completed
- owner: str | None # Agent 名(多 Agent 场景)
- blockedBy: list[str] # 依赖的任务 ID 列表
+ owner: str | None # Agent name (multi-agent scenarios)
+ blockedBy: list[str] # List of dependency task IDs
```
-ID 用 `timestamp + random hex` 生成,简单但够用。CC 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。
+IDs are generated with `timestamp + random hex`, simple but sufficient. CC uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design.
-### create_task: 创建任务
+### create_task: Create Tasks
```python
def create_task(subject: str, description: str = "",
@@ -76,11 +79,11 @@ def create_task(subject: str, description: str = "",
return task
```
-创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 "写 API" 的 `blockedBy` 是 `["task_schema"]`。
+Automatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example "write API" has `blockedBy: ["task_schema"]`.
-### can_start: 依赖检查
+### can_start: Dependency Check
-一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:
+A task can only start after all its `blockedBy` dependencies are **completed**:
```python
def can_start(task_id: str) -> bool:
@@ -94,11 +97,11 @@ def can_start(task_id: str) -> bool:
return True
```
-`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。
+`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs.
-### claim_task: 认领任务
+### claim_task: Claim a Task
-Agent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:
+When the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
@@ -115,18 +118,18 @@ def claim_task(task_id: str, owner: str = "agent") -> str:
return f"Claimed {task_id} ({task.subject})"
```
-如果任务已被别人认领(`status != "pending"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。
+If the task is already claimed by someone else (`status != "pending"`), or dependencies aren't met (`can_start` returns False), the claim is rejected.
-### complete_task: 完成与解锁
+### complete_task: Complete and Unblock
-任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:
+When a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**:
```python
def complete_task(task_id: str) -> str:
task = load_task(task_id)
task.status = "completed"
save_task(task)
- # 找出被解锁的下游任务
+ # Find newly unblocked downstream tasks
unblocked = [t.subject for t in list_tasks()
if t.status == "pending" and t.blockedBy
and can_start(t.id)]
@@ -136,11 +139,11 @@ def complete_task(task_id: str) -> str:
return msg
```
-完成 "schema" 后,"endpoints" 和 "docs" 的 `can_start` 返回 True,它们可以开始。
+After completing "schema", `can_start` returns True for "endpoints" and "docs"; they can begin.
-### get_task: 查看完整细节
+### get_task: View Full Details
-`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:
+`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work:
```python
def get_task(task_id: str) -> str:
@@ -148,134 +151,134 @@ def get_task(task_id: str) -> str:
return json.dumps(asdict(task), indent=2)
```
-### 状态机: 两个动作,三个状态
+### State Machine: Two Actions, Three States
```
pending ──claim──→ in_progress ──complete──→ completed
```
-这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:
+Here `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states:
-- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。
-- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。
+- **claim_task**: `pending` → `in_progress`. Sets owner, begins work.
+- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream.
-CC 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,CC 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。
+CC has no `in_progress → pending` release path. If a teammate terminates or shuts down, CC unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path.
-### 合起来跑
+### Putting It Together
```python
-# 创建有依赖的任务
+# Create tasks with dependencies
schema = create_task("setup database schema")
endpoints = create_task("create API endpoints", blockedBy=[schema.id])
tests = create_task("write tests", blockedBy=[endpoints.id])
docs = create_task("write docs", blockedBy=[schema.id])
-# Agent 认领第一个可做的任务
-claim_task(schema.id) # ✓ Claimed (无依赖)
-complete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs
+# Agent claims the first available task
+claim_task(schema.id) # ✓ Claimed (no dependencies)
+complete_task(schema.id) # ✓ Completed → unblocks endpoints, docs
-claim_task(endpoints.id) # ✓ Claimed (schema 已完成)
-complete_task(endpoints.id) # ✓ Completed → 解锁 tests
+claim_task(endpoints.id) # ✓ Claimed (schema completed)
+complete_task(endpoints.id) # ✓ Completed → unblocks tests
-claim_task(docs.id) # ✓ Claimed (schema 已完成)
+claim_task(docs.id) # ✓ Claimed (schema completed)
complete_task(docs.id) # ✓ Completed
-claim_task(tests.id) # ✓ Claimed (endpoints 已完成)
+claim_task(tests.id) # ✓ Claimed (endpoints completed)
complete_task(tests.id) # ✓ Completed
```
-每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。
+Each `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists — the agent reads the files to recover progress.
---
-## 相对 s11 的变更
+## Changes from s11
-| 组件 | 之前 (s11) | 之后 (s12) |
-|------|-----------|-----------|
-| 任务管理 | 无 | Task dataclass + 5 个工具 |
-| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |
-| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |
-| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |
-| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |
-| 生命周期 | — | pending → in_progress → completed(无 release 回退) |
+| Component | Before (s11) | After (s12) |
+|-----------|-------------|-------------|
+| Task management | None | Task dataclass + 5 tools |
+| New types | — | Task (id, subject, description, status, owner, blockedBy) |
+| Storage | No persistence | `.tasks/{id}.json` cross-session |
+| Dependencies | None | `blockedBy` graph + `can_start` check |
+| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |
+| Lifecycle | — | pending → in_progress → completed (no release rollback) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s12_task_system/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`
2. `List all tasks and their statuses`
3. `Claim the first unblocked task and complete it`
4. `List tasks again — which ones are now unblocked?`
-观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?
+What to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked?
---
-## 接下来
+## What's Next
-任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。
+The task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation.
-s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。
+s13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。
+> The following is a complete analysis based on CC source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines).
-### 一、TaskRecord 的完整字段
+### 1. TaskRecord's Full Fields
-教学版只讲了 id、subject、status、owner、blockedBy。CC 实际有 9 个字段(`utils/tasks.ts:76-89`):
+The tutorial only covers id, subject, status, owner, blockedBy. CC actually has 9 fields (`utils/tasks.ts:76-89`):
-| 字段 | 类型 | 用途 |
-|------|------|------|
-| `id` | string | 递增整数 ID |
-| `subject` | string | 简短标题 |
-| `description` | string | 自由格式描述 |
-| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 |
-| `owner` | string? | 分配的 agent ID |
-| `status` | pending/in_progress/completed | 生命周期 |
-| `blocks` | string[] | 此任务阻塞的任务 ID(下游) |
-| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) |
-| `metadata` | Record? | 任意扩展键值对 |
+| Field | Type | Purpose |
+|------|------|---------|
+| `id` | string | Incrementing integer ID |
+| `subject` | string | Short title |
+| `description` | string | Free-form description |
+| `activeForm` | string? | Present tense form, shown in spinner when in_progress |
+| `owner` | string? | Assigned agent ID |
+| `status` | pending/in_progress/completed | Lifecycle |
+| `blocks` | string[] | Task IDs blocked by this task (downstream) |
+| `blockedBy` | string[] | Task IDs blocking this task (upstream) |
+| `metadata` | Record? | Arbitrary extension key-value pairs |
-存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。
+Storage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task.
-### 二、不是 TodoWrite 的升级,是两个独立系统
+### 2. Same Intent, Independent Mechanisms
-CC 中 Task System 和 TodoWrite **同时存在**,通过 `isTodoV2Enabled()` 切换(`utils/tasks.ts:133`)——交互式会话默认启用 Task(V2),非交互式/SDK 默认用 TodoWrite。环境变量 `CLAUDE_CODE_ENABLE_TASKS` 可强制启用 Task。Task 有 TodoWrite 没有的:文件锁并发保护、依赖强制执行、ownership、fs.watch 响应式监听、生命周期 hooks。
+Task tools and TodoWrite can coexist, but they do not share one storage model. Current interactive sessions default to structured Task tools, while TodoWrite remains on compatibility surfaces such as non-interactive and Agent SDK usage; exact exposure varies by release and configuration. Task records add file-lock concurrency protection, dependency enforcement, ownership, reactive monitoring, and lifecycle hooks. TodoWrite remains a whole-list session checklist.
-### 三、并发认领的锁机制
+### 3. Concurrent Claim Locking
-`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争:
+`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races:
-**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内:
-1. 重新读取任务(防 TOCTOU)
-2. 检查已被他人认领 → `already_claimed`
-3. 检查已完成 → `already_resolved`
-4. 检查上游未完成 → `blocked`
-5. 设置 owner
+**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock:
+1. Re-read task (prevent TOCTOU)
+2. Check already claimed by another → `already_claimed`
+3. Check already completed → `already_resolved`
+4. Check upstream not completed → `blocked`
+5. Set owner
-**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。
+**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks.
-注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 CC 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。
+Note: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real CC's `claimTask` primarily resolves owner competition — it only sets owner without changing status. Status updates are handled by `TaskUpdate`.
-### 四、高水位标防 ID 重用
+### 4. High-Water Mark to Prevent ID Reuse
-`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。
+The `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused.
-### 五、四个 Task 工具
+### 5. Four Task Tools
-CC 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。
+CC's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch).
-教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 CC 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。
+The teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real CC's `TaskCreate` only accepts subject/description/activeForm/metadata — dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.
diff --git a/s12_task_system/README.zh.md b/s12_task_system/README.zh.md
new file mode 100644
index 00000000..10473d6d
--- /dev/null
+++ b/s12_task_system/README.zh.md
@@ -0,0 +1,285 @@
+# s12: Task System — 目标太大,拆成小任务
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
+
+> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。
+>
+> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。
+
+---
+
+## 问题
+
+Agent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...
+
+盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。
+
+s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。
+
+---
+
+## 解决方案
+
+
+
+教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个教学工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:CC 源码中的任务模块管理任务状态,query recovery 管理模型调用失败。
+
+TodoWrite vs Task System:
+
+| | TodoWrite (s05) | Task System (s12) |
+|---|---|---|
+| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |
+| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |
+| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |
+| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |
+| 分工 | 不负责任务认领 | `owner` / claim |
+| 状态 | pending / in_progress / completed | pending / in_progress / completed |
+| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |
+| 更新契约 | 整表替换 | 对单条记录执行创建、读取、更新、列举 |
+
+教学 API 用 `create_task`、`list_tasks`、`get_task`、`claim_task`、`complete_task` 把生命周期展开。Claude Code 的产品表面把这些操作归入四个工具:`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`;认领与完成属于更新,不是独立的官方工具。
+
+---
+
+## 工作原理
+
+
+
+### Task: 数据结构
+
+每个任务是一个 JSON 文件,存于 `.tasks/` 目录:
+
+```python
+@dataclass
+class Task:
+ id: str
+ subject: str
+ description: str
+ status: str # pending | in_progress | completed
+ owner: str | None # Agent 名(多 Agent 场景)
+ blockedBy: list[str] # 依赖的任务 ID 列表
+```
+
+ID 用 `timestamp + random hex` 生成,简单但够用。CC 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。
+
+### create_task: 创建任务
+
+```python
+def create_task(subject: str, description: str = "",
+ blockedBy: list[str] | None = None) -> Task:
+ task = Task(
+ id=f"task_{int(time.time())}_{random_hex(4)}",
+ subject=subject, description=description,
+ status="pending", owner=None,
+ blockedBy=blockedBy or [],
+ )
+ save_task(task)
+ return task
+```
+
+创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 "写 API" 的 `blockedBy` 是 `["task_schema"]`。
+
+### can_start: 依赖检查
+
+一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:
+
+```python
+def can_start(task_id: str) -> bool:
+ task = load_task(task_id)
+ for dep_id in task.blockedBy:
+ if not _task_path(dep_id).exists():
+ return False # missing dependency = blocked
+ dep = load_task(dep_id)
+ if dep.status != "completed":
+ return False
+ return True
+```
+
+`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。
+
+### claim_task: 认领任务
+
+Agent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:
+
+```python
+def claim_task(task_id: str, owner: str = "agent") -> str:
+ task = load_task(task_id)
+ if task.status != "pending":
+ return f"Task {task_id} is {task.status}, cannot claim"
+ if not can_start(task_id):
+ deps = [d for d in task.blockedBy
+ if load_task(d).status != "completed"]
+ return f"Blocked by: {deps}"
+ task.owner = owner
+ task.status = "in_progress"
+ save_task(task)
+ return f"Claimed {task_id} ({task.subject})"
+```
+
+如果任务已被别人认领(`status != "pending"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。
+
+### complete_task: 完成与解锁
+
+任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:
+
+```python
+def complete_task(task_id: str) -> str:
+ task = load_task(task_id)
+ task.status = "completed"
+ save_task(task)
+ # 找出被解锁的下游任务
+ unblocked = [t.subject for t in list_tasks()
+ if t.status == "pending" and t.blockedBy
+ and can_start(t.id)]
+ msg = f"Completed {task_id} ({task.subject})"
+ if unblocked:
+ msg += f"\nUnblocked: {', '.join(unblocked)}"
+ return msg
+```
+
+完成 "schema" 后,"endpoints" 和 "docs" 的 `can_start` 返回 True,它们可以开始。
+
+### get_task: 查看完整细节
+
+`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:
+
+```python
+def get_task(task_id: str) -> str:
+ task = load_task(task_id)
+ return json.dumps(asdict(task), indent=2)
+```
+
+### 状态机: 两个动作,三个状态
+
+```
+pending ──claim──→ in_progress ──complete──→ completed
+```
+
+这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:
+
+- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。
+- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。
+
+CC 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,CC 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。
+
+### 合起来跑
+
+```python
+# 创建有依赖的任务
+schema = create_task("setup database schema")
+endpoints = create_task("create API endpoints", blockedBy=[schema.id])
+tests = create_task("write tests", blockedBy=[endpoints.id])
+docs = create_task("write docs", blockedBy=[schema.id])
+
+# Agent 认领第一个可做的任务
+claim_task(schema.id) # ✓ Claimed (无依赖)
+complete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs
+
+claim_task(endpoints.id) # ✓ Claimed (schema 已完成)
+complete_task(endpoints.id) # ✓ Completed → 解锁 tests
+
+claim_task(docs.id) # ✓ Claimed (schema 已完成)
+complete_task(docs.id) # ✓ Completed
+
+claim_task(tests.id) # ✓ Claimed (endpoints 已完成)
+complete_task(tests.id) # ✓ Completed
+```
+
+每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。
+
+---
+
+## 相对 s11 的变更
+
+| 组件 | 之前 (s11) | 之后 (s12) |
+|------|-----------|-----------|
+| 任务管理 | 无 | Task dataclass + 5 个工具 |
+| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |
+| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |
+| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |
+| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |
+| 生命周期 | — | pending → in_progress → completed(无 release 回退) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s12_task_system/code.py
+```
+
+试试这些 prompt:
+
+1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`
+2. `List all tasks and their statuses`
+3. `Claim the first unblocked task and complete it`
+4. `List tasks again — which ones are now unblocked?`
+
+观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?
+
+---
+
+## 接下来
+
+任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。
+
+s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。
+
+### 一、TaskRecord 的完整字段
+
+教学版只讲了 id、subject、status、owner、blockedBy。CC 实际有 9 个字段(`utils/tasks.ts:76-89`):
+
+| 字段 | 类型 | 用途 |
+|------|------|------|
+| `id` | string | 递增整数 ID |
+| `subject` | string | 简短标题 |
+| `description` | string | 自由格式描述 |
+| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 |
+| `owner` | string? | 分配的 agent ID |
+| `status` | pending/in_progress/completed | 生命周期 |
+| `blocks` | string[] | 此任务阻塞的任务 ID(下游) |
+| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) |
+| `metadata` | Record? | 任意扩展键值对 |
+
+存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。
+
+### 二、目标相近,机制独立
+
+Task 工具与 TodoWrite 可以同时存在,但不共享一套存储模型。当前交互式会话默认使用结构化 Task 工具;TodoWrite 仍位于非交互式、Agent SDK 等兼容表面,具体暴露方式会随版本和配置变化。Task 记录增加了文件锁并发保护、依赖强制执行、ownership、响应式监听和生命周期 hooks;TodoWrite 仍是整表替换的会话清单。
+
+### 三、并发认领的锁机制
+
+`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争:
+
+**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内:
+1. 重新读取任务(防 TOCTOU)
+2. 检查已被他人认领 → `already_claimed`
+3. 检查已完成 → `already_resolved`
+4. 检查上游未完成 → `blocked`
+5. 设置 owner
+
+**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。
+
+注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 CC 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。
+
+### 四、高水位标防 ID 重用
+
+`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。
+
+### 五、四个 Task 工具
+
+CC 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。
+
+教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 CC 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。
+
+
+
+
diff --git a/s13_background_tasks/README.en.md b/s13_background_tasks/README.en.md
deleted file mode 100644
index f02ee3af..00000000
--- a/s13_background_tasks/README.en.md
+++ /dev/null
@@ -1,261 +0,0 @@
-# s13: Background Tasks — Slow Operations Go to the Background
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20
-
-> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done.
->
-> **Harness Layer**: Background — Async execution, doesn't block the main loop.
-
----
-
-## The Problem
-
-Ever used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes.
-
-The agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks.
-
-Reading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste.
-
----
-
-## The Solution
-
-
-
-Teaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: slow operations go to background threads, the agent continues running the loop, and background results are injected as notifications.
-
-Sync vs Background:
-
-| | Sync (s12) | Background (s13) |
-|---|---|---|
-| Slow operations | Agent waits | Background thread executes |
-| Agent idle | Yes | No, continues processing |
-| Result | Immediate return | Notification injected next turn |
-| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback |
-
----
-
-## How It Works
-
-### should_run_background: Explicit Request First, Heuristic Fallback
-
-The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics:
-
-```python
-def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
- """Fallback heuristic: commands likely to take > 30s."""
- if tool_name != "bash":
- return False
- cmd = tool_input.get("command", "").lower()
- slow_keywords = ["install", "build", "test", "deploy", "compile",
- "docker build", "pip install", "npm install",
- "cargo build", "pytest", "make"]
- return any(kw in cmd for kw in slow_keywords)
-
-def should_run_background(tool_name: str, tool_input: dict) -> bool:
- """Model explicit request takes priority; fallback to heuristic."""
- if tool_input.get("run_in_background"):
- return True
- return is_slow_operation(tool_name, tool_input)
-```
-
-CC's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request.
-
-### start_background_task: Background Execution and Lifecycle
-
-Wraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict:
-
-```python
-_bg_counter = 0
-background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}
-background_results: dict[str, str] = {} # bg_id → output
-background_lock = threading.Lock()
-
-def start_background_task(block) -> str:
- """Run tool in a daemon thread. Returns background task ID."""
- global _bg_counter
- _bg_counter += 1
- bg_id = f"bg_{_bg_counter:04d}"
-
- def worker():
- result = execute_tool(block)
- with background_lock:
- background_tasks[bg_id]["status"] = "completed"
- background_results[bg_id] = result
-
- with background_lock:
- background_tasks[bg_id] = {
- "tool_use_id": block.id,
- "command": block.input.get("command", ""),
- "status": "running",
- }
- thread = threading.Thread(target=worker, daemon=True)
- thread.start()
- return bg_id
-```
-
-Returns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real CC has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output.
-
-### collect_background_results: Notification Collection
-
-When background tasks complete, results are collected and formatted as `` messages:
-
-```python
-def collect_background_results() -> list[str]:
- """Collect completed results as task_notification messages."""
- with background_lock:
- ready_ids = [bid for bid, task in background_tasks.items()
- if task["status"] == "completed"]
- notifications = []
- for bg_id in ready_ids:
- with background_lock:
- task = background_tasks.pop(bg_id)
- output = background_results.pop(bg_id, "")
- notifications.append(
- f"\n"
- f" {bg_id}\n"
- f" completed\n"
- f" {task['command']}\n"
- f" {output[:200]}\n"
- f"")
- return notifications
-```
-
-Notifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`.
-
-### Loop Integration
-
-In the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message:
-
-```python
-results = []
-for block in response.content:
- if block.type != "tool_use":
- continue
- if should_run_background(block.name, block.input):
- bg_id = start_background_task(block)
- results.append({"type": "tool_result",
- "tool_use_id": block.id,
- "content": f"[Background task {bg_id} started] "
- f"Result will be available when complete."})
- else:
- output = execute_tool(block)
- results.append({"type": "tool_result",
- "tool_use_id": block.id, "content": output})
-
-# Merge notifications and tool results into one user message
-user_content = []
-bg_notifications = collect_background_results()
-if bg_notifications:
- for notif in bg_notifications:
- user_content.append({"type": "text", "text": notif})
-user_content.extend(results)
-messages.append({"role": "user", "content": user_content})
-```
-
-Slow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message.
-
-The teaching version polls background results while the agent loop continues running. Real CC uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop.
-
-### Putting It Together
-
-```
-Turn 1:
- LLM → bash "npm install" (run_in_background=true)
- → start_background_task → bg_0001
- → tool_result: "[Background task bg_0001 started]..."
- → LLM: "OK, I'll check later. Let me also read the config."
-
-Turn 2:
- LLM → read_file "package.json" (fast, sync)
- → tool_result: file content
- → collect: bg_0001 done! inject
- → LLM sees: config file + install notification in one message
-```
-
-The agent didn't wait — while npm install ran in the background, it read the config file.
-
----
-
-## Changes from s12
-
-| Component | Before (s12) | After (s13) |
-|-----------|-------------|-------------|
-| Execution model | All synchronous | Slow ops to background thread + notification injection |
-| bash schema | `command` | `command` + `run_in_background` |
-| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |
-| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |
-| Notification format | — | `` (doesn't reuse tool_use_id) |
-| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn |
-| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s13_background_tasks/code.py
-```
-
-Try these prompts:
-
-1. `Run pip list in the background and find all Python files in this directory`
-2. `Run npm install (use run_in_background) and while waiting, read package.json`
-3. `Create a task to setup the project, then run pip list in the background`
-
-What to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format?
-
----
-
-## What's Next
-
-Background tasks solved "slow operations don't block." But what if you want to do something on a schedule? Like "run tests every morning at 9am" or "check server status every 5 minutes."
-
-s14 Cron Scheduler → Give the agent an alarm clock.
-
-
-Deep Dive into CC Source
-
-> The following is a complete analysis based on CC source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`).
-
-### 1. pendingToolUseSummary: Haiku Background Generation
-
-CC starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is "Write a short summary label... think git-commit-subject, not sentence", past tense, ~30 characters.
-
-Haiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display.
-
-### 2. Thread Model: No Real Threads
-
-CC runs on Node.js/Bun's single-threaded event loop. "Background" just means "don't await". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently.
-
-### 3. Seven Background Task Types
-
-CC defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism.
-
-### 4. Notification Injection: Command Queue
-
-When a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML:
-
-```xml
-
- completed
- Background command "npm test" completed (exit code 0)
-
-```
-
-Priority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`.
-
-### 5. Stall Watchdog
-
-Background bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs.
-
-### 6. Concurrency Limits
-
-Foreground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.
-
-
-
-
diff --git a/s13_background_tasks/README.ja.md b/s13_background_tasks/README.ja.md
index 7ee1d14d..8941f78f 100644
--- a/s13_background_tasks/README.ja.md
+++ b/s13_background_tasks/README.ja.md
@@ -1,8 +1,8 @@
# s13: Background Tasks — 遅い操作はバックグラウンドへ
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20
+s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
> *"遅い操作はバックグラウンドへ、agent は処理を継続"* — バックグラウンドスレッドでコマンドを実行、完了時に通知を注入。
>
diff --git a/s13_background_tasks/README.md b/s13_background_tasks/README.md
index fe284cd8..f8735738 100644
--- a/s13_background_tasks/README.md
+++ b/s13_background_tasks/README.md
@@ -1,47 +1,47 @@
-# s13: Background Tasks — 慢操作放后台
+# s13: Background Tasks — Slow Operations Go to the Background
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20
+s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
-> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。
+> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done.
>
-> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。
+> **Harness Layer**: Background — Async execution, doesn't block the main loop.
---
-## 问题
+## The Problem
-你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。
+Ever used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes.
-Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。
+The agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks.
-读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。
+Reading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste.
---
-## 解决方案
+## The Solution
-
+
-教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:慢操作扔到后台线程,Agent 继续跑循环,后台完成后把通知注入到对话里。
+Teaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: slow operations go to background threads, the agent continues running the loop, and background results are injected as notifications.
-同步 vs 后台:
+Sync vs Background:
-| | 同步 (s12) | 后台 (s13) |
+| | Sync (s12) | Background (s13) |
|---|---|---|
-| 慢操作 | Agent 干等 | 后台线程执行 |
-| Agent 空闲 | 是 | 否,继续处理 |
-| 结果 | 立即返回 | 下轮注入通知 |
-| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |
+| Slow operations | Agent waits | Background thread executes |
+| Agent idle | Yes | No, continues processing |
+| Result | Immediate return | Notification injected next turn |
+| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback |
---
-## 工作原理
+## How It Works
-### should_run_background: 显式请求优先,启发式兜底
+### should_run_background: Explicit Request First, Heuristic Fallback
-模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底:
+The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics:
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -61,11 +61,11 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool:
return is_slow_operation(tool_name, tool_input)
```
-CC 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。
+CC's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request.
-### start_background_task: 后台执行与生命周期
+### start_background_task: Background Execution and Lifecycle
-把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:
+Wraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict:
```python
_bg_counter = 0
@@ -96,11 +96,11 @@ def start_background_task(block) -> str:
return bg_id
```
-返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 CC 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。
+Returns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real CC has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output.
-### collect_background_results: 通知收集
+### collect_background_results: Notification Collection
-后台任务完成后,收集结果并格式化为 `` 通知:
+When background tasks complete, results are collected and formatted as `` messages:
```python
def collect_background_results() -> list[str]:
@@ -123,11 +123,11 @@ def collect_background_results() -> list[str]:
return notifications
```
-通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。
+Notifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`.
-### 循环中的集成
+### Loop Integration
-agent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:
+In the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message:
```python
results = []
@@ -145,7 +145,7 @@ for block in response.content:
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
-# 通知和工具结果合入同一条 user 消息
+# Merge notifications and tool results into one user message
user_content = []
bg_notifications = collect_background_results()
if bg_notifications:
@@ -155,11 +155,11 @@ user_content.extend(results)
messages.append({"role": "user", "content": user_content})
```
-慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。
+Slow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message.
-教学版在 agent loop 继续运行时轮询后台结果。真实 CC 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。
+The teaching version polls background results while the agent loop continues running. Real CC uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop.
-### 合起来跑
+### Putting It Together
```
Turn 1:
@@ -175,69 +175,69 @@ Turn 2:
→ LLM sees: config file + install notification in one message
```
-Agent 没干等,npm install 跑后台的时候,它去读了配置文件。
+The agent didn't wait — while npm install ran in the background, it read the config file.
---
-## 相对 s12 的变更
+## Changes from s12
-| 组件 | 之前 (s12) | 之后 (s13) |
-|------|-----------|-----------|
-| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |
+| Component | Before (s12) | After (s13) |
+|-----------|-------------|-------------|
+| Execution model | All synchronous | Slow ops to background thread + notification injection |
| bash schema | `command` | `command` + `run_in_background` |
-| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |
-| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |
-| 通知格式 | — | ``(不复用 tool_use_id) |
-| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |
-| 工具 | 8 (s12) | 8(不变,执行策略变了) |
+| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |
+| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |
+| Notification format | — | `` (doesn't reuse tool_use_id) |
+| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn |
+| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s13_background_tasks/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Run pip list in the background and find all Python files in this directory`
2. `Run npm install (use run_in_background) and while waiting, read package.json`
3. `Create a task to setup the project, then run pip list in the background`
-观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?
+What to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format?
---
-## 接下来
+## What's Next
-后台任务解决了"慢操作不阻塞"。但如果想定时做某件事呢?比如"每天早上 9 点跑测试"、"每 5 分钟检查一次服务器状态"。
+Background tasks solved "slow operations don't block." But what if you want to do something on a schedule? Like "run tests every morning at 9am" or "check server status every 5 minutes."
-s14 Cron Scheduler → 给 Agent 装一个闹钟。
+s14 Cron Scheduler → Give the agent an alarm clock.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。
+> The following is a complete analysis based on CC source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`).
-### 一、pendingToolUseSummary:Haiku 后台生成
+### 1. pendingToolUseSummary: Haiku Background Generation
-CC 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 "Write a short summary label... think git-commit-subject, not sentence",过去时态,约 30 字符。
+CC starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is "Write a short summary label... think git-commit-subject, not sentence", past tense, ~30 characters.
-Haiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。
+Haiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display.
-### 二、线程模型:没有真正的线程
+### 2. Thread Model: No Real Threads
-CC 运行在 Node.js/Bun 单线程事件循环中。"后台"只是 "不 await"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。
+CC runs on Node.js/Bun's single-threaded event loop. "Background" just means "don't await". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently.
-### 三、七种后台任务类型
+### 3. Seven Background Task Types
-CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。
+CC defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism.
-### 四、通知注入:命令队列
+### 4. Notification Injection: Command Queue
-后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML:
+When a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML:
```xml
@@ -246,15 +246,15 @@ CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent
```
-优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。
+Priority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`.
-### 五、停滞看门狗
+### 5. Stall Watchdog
-后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。
+Background bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs.
-### 六、并发限制
+### 6. Concurrency Limits
-前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。
+Foreground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.
diff --git a/s13_background_tasks/README.zh.md b/s13_background_tasks/README.zh.md
new file mode 100644
index 00000000..99b9bad2
--- /dev/null
+++ b/s13_background_tasks/README.zh.md
@@ -0,0 +1,261 @@
+# s13: Background Tasks — 慢操作放后台
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
+
+> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。
+>
+> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。
+
+---
+
+## 问题
+
+你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。
+
+Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。
+
+读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。
+
+---
+
+## 解决方案
+
+
+
+教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:慢操作扔到后台线程,Agent 继续跑循环,后台完成后把通知注入到对话里。
+
+同步 vs 后台:
+
+| | 同步 (s12) | 后台 (s13) |
+|---|---|---|
+| 慢操作 | Agent 干等 | 后台线程执行 |
+| Agent 空闲 | 是 | 否,继续处理 |
+| 结果 | 立即返回 | 下轮注入通知 |
+| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |
+
+---
+
+## 工作原理
+
+### should_run_background: 显式请求优先,启发式兜底
+
+模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底:
+
+```python
+def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
+ """Fallback heuristic: commands likely to take > 30s."""
+ if tool_name != "bash":
+ return False
+ cmd = tool_input.get("command", "").lower()
+ slow_keywords = ["install", "build", "test", "deploy", "compile",
+ "docker build", "pip install", "npm install",
+ "cargo build", "pytest", "make"]
+ return any(kw in cmd for kw in slow_keywords)
+
+def should_run_background(tool_name: str, tool_input: dict) -> bool:
+ """Model explicit request takes priority; fallback to heuristic."""
+ if tool_input.get("run_in_background"):
+ return True
+ return is_slow_operation(tool_name, tool_input)
+```
+
+CC 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。
+
+### start_background_task: 后台执行与生命周期
+
+把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:
+
+```python
+_bg_counter = 0
+background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}
+background_results: dict[str, str] = {} # bg_id → output
+background_lock = threading.Lock()
+
+def start_background_task(block) -> str:
+ """Run tool in a daemon thread. Returns background task ID."""
+ global _bg_counter
+ _bg_counter += 1
+ bg_id = f"bg_{_bg_counter:04d}"
+
+ def worker():
+ result = execute_tool(block)
+ with background_lock:
+ background_tasks[bg_id]["status"] = "completed"
+ background_results[bg_id] = result
+
+ with background_lock:
+ background_tasks[bg_id] = {
+ "tool_use_id": block.id,
+ "command": block.input.get("command", ""),
+ "status": "running",
+ }
+ thread = threading.Thread(target=worker, daemon=True)
+ thread.start()
+ return bg_id
+```
+
+返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 CC 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。
+
+### collect_background_results: 通知收集
+
+后台任务完成后,收集结果并格式化为 `` 通知:
+
+```python
+def collect_background_results() -> list[str]:
+ """Collect completed results as task_notification messages."""
+ with background_lock:
+ ready_ids = [bid for bid, task in background_tasks.items()
+ if task["status"] == "completed"]
+ notifications = []
+ for bg_id in ready_ids:
+ with background_lock:
+ task = background_tasks.pop(bg_id)
+ output = background_results.pop(bg_id, "")
+ notifications.append(
+ f"\n"
+ f" {bg_id}\n"
+ f" completed\n"
+ f" {task['command']}\n"
+ f" {output[:200]}\n"
+ f"")
+ return notifications
+```
+
+通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。
+
+### 循环中的集成
+
+agent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:
+
+```python
+results = []
+for block in response.content:
+ if block.type != "tool_use":
+ continue
+ if should_run_background(block.name, block.input):
+ bg_id = start_background_task(block)
+ results.append({"type": "tool_result",
+ "tool_use_id": block.id,
+ "content": f"[Background task {bg_id} started] "
+ f"Result will be available when complete."})
+ else:
+ output = execute_tool(block)
+ results.append({"type": "tool_result",
+ "tool_use_id": block.id, "content": output})
+
+# 通知和工具结果合入同一条 user 消息
+user_content = []
+bg_notifications = collect_background_results()
+if bg_notifications:
+ for notif in bg_notifications:
+ user_content.append({"type": "text", "text": notif})
+user_content.extend(results)
+messages.append({"role": "user", "content": user_content})
+```
+
+慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。
+
+教学版在 agent loop 继续运行时轮询后台结果。真实 CC 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。
+
+### 合起来跑
+
+```
+Turn 1:
+ LLM → bash "npm install" (run_in_background=true)
+ → start_background_task → bg_0001
+ → tool_result: "[Background task bg_0001 started]..."
+ → LLM: "OK, I'll check later. Let me also read the config."
+
+Turn 2:
+ LLM → read_file "package.json" (fast, sync)
+ → tool_result: file content
+ → collect: bg_0001 done! inject
+ → LLM sees: config file + install notification in one message
+```
+
+Agent 没干等,npm install 跑后台的时候,它去读了配置文件。
+
+---
+
+## 相对 s12 的变更
+
+| 组件 | 之前 (s12) | 之后 (s13) |
+|------|-----------|-----------|
+| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |
+| bash schema | `command` | `command` + `run_in_background` |
+| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |
+| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |
+| 通知格式 | — | ``(不复用 tool_use_id) |
+| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |
+| 工具 | 8 (s12) | 8(不变,执行策略变了) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s13_background_tasks/code.py
+```
+
+试试这些 prompt:
+
+1. `Run pip list in the background and find all Python files in this directory`
+2. `Run npm install (use run_in_background) and while waiting, read package.json`
+3. `Create a task to setup the project, then run pip list in the background`
+
+观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?
+
+---
+
+## 接下来
+
+后台任务解决了"慢操作不阻塞"。但如果想定时做某件事呢?比如"每天早上 9 点跑测试"、"每 5 分钟检查一次服务器状态"。
+
+s14 Cron Scheduler → 给 Agent 装一个闹钟。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。
+
+### 一、pendingToolUseSummary:Haiku 后台生成
+
+CC 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 "Write a short summary label... think git-commit-subject, not sentence",过去时态,约 30 字符。
+
+Haiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。
+
+### 二、线程模型:没有真正的线程
+
+CC 运行在 Node.js/Bun 单线程事件循环中。"后台"只是 "不 await"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。
+
+### 三、七种后台任务类型
+
+CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。
+
+### 四、通知注入:命令队列
+
+后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML:
+
+```xml
+
+ completed
+ Background command "npm test" completed (exit code 0)
+
+```
+
+优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。
+
+### 五、停滞看门狗
+
+后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。
+
+### 六、并发限制
+
+前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。
+
+
+
+
diff --git a/s14_cron_scheduler/README.en.md b/s14_cron_scheduler/README.en.md
deleted file mode 100644
index 34ca4c91..00000000
--- a/s14_cron_scheduler/README.en.md
+++ /dev/null
@@ -1,305 +0,0 @@
-# s14: Cron Scheduler — Producing Work on a Schedule
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
-> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level.
->
-> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.
-
----
-
-## The Problem
-
-An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless.
-
-s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes" — these recurring tasks shouldn't need a human to push them each time.
-
----
-
-## The Solution
-
-
-
-Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.
-
-Manual vs Scheduled:
-
-| | Manual (s13) | Scheduled (s14) |
-|---|---|---|
-| Triggered by | User input | Scheduler thread |
-| Trigger timing | Anytime | Specified by cron expression |
-| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) |
-| Persistence | — | Durable survives restart |
-
----
-
-## How It Works
-
-### Four-Layer Model
-
-Cron scheduling has four layers:
-
-1. **Scheduler**: daemon thread, polls every second, checks if it's time
-2. **Queue**: `cron_queue`, scheduler writes fired jobs
-3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn
-4. **Consumer**: agent_loop consumes queue and injects into messages
-
-The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.
-
-### CronJob: Data Structure
-
-Each cron task is a `CronJob` object:
-
-```python
-@dataclass
-class CronJob:
- id: str
- cron: str # "0 9 * * *" (5-field cron expression)
- prompt: str # Message injected to the agent when fired
- recurring: bool # True=recurring, False=one-shot
- durable: bool # True=write to disk, survives sessions
-```
-
-Cron expression, 5 fields, used by Unix for 50 years:
-
-```
-min hour dom month dow
- * * * * * Every minute
- 0 9 * * * Every day at 9:00
-*/5 * * * * Every 5 minutes
- 0 9 * * 1-5 Weekdays at 9:00
-```
-
-Supports `*`, `*/N`, `N`, `N-M`, `N,M,...`.
-
-### cron_matches: 5-Field Matching
-
-Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:
-
-```python
-def cron_matches(cron_expr: str, dt: datetime) -> bool:
- fields = cron_expr.strip().split()
- if len(fields) != 5:
- return False
- minute, hour, dom, month, dow = fields
- dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
-
- m = _cron_field_matches(minute, dt.minute)
- h = _cron_field_matches(hour, dt.hour)
- dom_ok = _cron_field_matches(dom, dt.day)
- month_ok = _cron_field_matches(month, dt.month)
- dow_ok = _cron_field_matches(dow, dow_val)
-
- if not (m and h and month_ok):
- return False
- # DOM and DOW: both constrained → either matching is enough (OR)
- dom_unconstrained = dom == "*"
- dow_unconstrained = dow == "*"
- if dom_unconstrained and dow_unconstrained:
- return True
- if dom_unconstrained:
- return dow_ok
- if dow_unconstrained:
- return dom_ok
- return dom_ok or dow_ok
-```
-
-### Independent Scheduler Thread: 1-Second Polling
-
-The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:
-
-```python
-def cron_scheduler_loop():
- while True:
- time.sleep(1)
- now = datetime.now()
- minute_marker = now.strftime("%Y-%m-%d %H:%M")
- with cron_lock:
- for job in list(scheduled_jobs.values()):
- try:
- if cron_matches(job.cron, now):
- if _last_fired.get(job.id) != minute_marker:
- cron_queue.append(job)
- _last_fired[job.id] = minute_marker
- if not job.recurring:
- scheduled_jobs.pop(job.id, None)
- if job.durable:
- save_durable_jobs()
- except Exception as e:
- print(f"[cron error] {job.id}: {e}")
-```
-
-Key design:
-- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running
-- **Date-aware minute_marker**: uses `"YYYY-MM-DD HH:MM"` to prevent same-minute double-fire while not skipping on the next day
-- **Per-job try/except**: one bad job doesn't crash the scheduler thread
-- **One-shot jobs**: auto-removed from scheduled_jobs after firing
-
-### Queue Processor + agent_loop: Delivery
-
-The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:
-
-```python
-def queue_processor_loop():
- while True:
- time.sleep(0.2)
- if not has_cron_queue():
- continue
- if not agent_lock.acquire(blocking=False):
- continue
- try:
- if has_cron_queue():
- run_agent_turn_locked()
- finally:
- agent_lock.release()
-```
-
-agent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages:
-
-```python
-fired = consume_cron_queue()
-for job in fired:
- messages.append({"role": "user",
- "content": f"[Scheduled] {job.prompt}"})
-```
-
-Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`.
-
-### Validation: Prevent Bad Cron from Killing the Scheduler
-
-`schedule_job` validates the cron expression before registering, returning an error for invalid input:
-
-```python
-def schedule_job(cron, prompt, recurring=True, durable=True):
- err = validate_cron(cron)
- if err:
- return err
- # ... register job
-```
-
-Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.
-
-### Durable vs Session-only
-
-- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart.
-- **Session-only**: In-memory only. Gone when the agent closes.
-
-> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers "it should fire" and fires. If you need "run even when the app is closed", use system crontab or systemd timer.
-
-### Putting It Together
-
-```
-1. On startup:
- load_durable_jobs() → restore durable tasks from .scheduled_tasks.json
- Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling
- Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver
-
-2. Register a task:
- schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
- → CronJob written to scheduled_jobs + .scheduled_tasks.json
-
-3. Every 2 minutes:
- Scheduler checks → cron_matches returns True → cron_queue.append(job)
- → queue processor sees idle agent → agent_loop consume_cron_queue
- → injects "[Scheduled] run date"
- → LLM receives message, runs date command
-
-4. Process shutdown:
- Scheduler thread stops (daemon=True)
- .scheduled_tasks.json stays on disk
- Next startup → load_durable_jobs → tasks restored
-```
-
----
-
-## Changes from s13
-
-| Component | Before (s13) | After (s14) |
-|-----------|-------------|-------------|
-| Trigger method | User manual trigger | Scheduler thread auto-enqueues |
-| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
-| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
-| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) |
-| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread |
-| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) |
-| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s14_cron_scheduler/code.py
-```
-
-Try these prompts:
-
-1. `Schedule a task to print the current date every 2 minutes`
-2. `List all cron jobs`
-3. `Create a one-shot reminder in 1 minute to check the build status`
-4. `Cancel the recurring job and verify with list_crons`
-
-What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`?
-
----
-
-## What's Next
-
-One agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent.
-
-"Refactor the entire backend" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team.
-
-s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.
-
-
-Deep Dive into CC Source
-
-> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).
-
-### 1. Three Cron Tools
-
-CC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.
-
-### 2. Storage: `.claude/scheduled_tasks.json`
-
-```json
-{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
-```
-
-Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.
-
-### 3. Scheduler: 1-Second Polling
-
-`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.
-
-### 4. Cron Expression: Standard 5 Fields
-
-Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.
-
-### 5. Jitter (Thundering Herd Prevention)
-
-- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID
-- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`
-- Jitter config adjustable via GrowthBook, refreshed every 60 seconds
-
-### 6. Auto-Expiration
-
-Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.
-
-### 7. Job Limit
-
-`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first."
-
-### 8. Trigger Injection
-
-After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.
-
-### 9. Queue Processor: Automatic Delivery
-
-Real CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
-
-
-
-
diff --git a/s14_cron_scheduler/README.ja.md b/s14_cron_scheduler/README.ja.md
index 281f2f33..d97d475d 100644
--- a/s14_cron_scheduler/README.ja.md
+++ b/s14_cron_scheduler/README.ja.md
@@ -1,8 +1,8 @@
# s14: Cron Scheduler — スケジュールに従って作業を生産
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
+s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
> *"スケジュールに従って作業を生産、スケジューリングと実行を分離"* — cron スケジューリング、永続またはセッションレベル。
>
> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。
diff --git a/s14_cron_scheduler/README.md b/s14_cron_scheduler/README.md
index 492ba8a9..5e9122ff 100644
--- a/s14_cron_scheduler/README.md
+++ b/s14_cron_scheduler/README.md
@@ -1,81 +1,81 @@
-# s14: Cron Scheduler — 按时间表生产工作
+# s14: Cron Scheduler — Producing Work on a Schedule
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
-> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
+s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
+> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level.
>
-> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。
+> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.
---
-## 问题
+## The Problem
-闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。
+An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless.
-s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。
+s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes" — these recurring tasks shouldn't need a human to push them each time.
---
-## 解决方案
+## The Solution
-
+
-教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。
+Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.
-手动 vs 定时:
+Manual vs Scheduled:
-| | 手动触发 (s13) | 定时触发 (s14) |
+| | Manual (s13) | Scheduled (s14) |
|---|---|---|
-| 触发者 | 用户输入 | 调度线程 |
-| 触发时机 | 随时 | cron 表达式指定 |
-| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |
-| 持久性 | — | durable 跨重启 |
+| Triggered by | User input | Scheduler thread |
+| Trigger timing | Anytime | Specified by cron expression |
+| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) |
+| Persistence | — | Durable survives restart |
---
-## 工作原理
+## How It Works
-### 四层模型
+### Four-Layer Model
-Cron 调度分四层:
+Cron scheduling has four layers:
-1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有
-2. **Queue**:`cron_queue`,调度线程写入已触发任务
-3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop
-4. **Consumer**:agent_loop 从队列消费,注入到 messages
+1. **Scheduler**: daemon thread, polls every second, checks if it's time
+2. **Queue**: `cron_queue`, scheduler writes fired jobs
+3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn
+4. **Consumer**: agent_loop consumes queue and injects into messages
-教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。
+The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.
-### CronJob: 数据结构
+### CronJob: Data Structure
-每个 cron 任务是一个 `CronJob` 对象:
+Each cron task is a `CronJob` object:
```python
@dataclass
class CronJob:
id: str
- cron: str # "0 9 * * *" (五段式 cron 表达式)
- prompt: str # 触发时注入给 Agent 的消息
- recurring: bool # True=周期性,False=一次性
- durable: bool # True=写磁盘,跨会话保留
+ cron: str # "0 9 * * *" (5-field cron expression)
+ prompt: str # Message injected to the agent when fired
+ recurring: bool # True=recurring, False=one-shot
+ durable: bool # True=write to disk, survives sessions
```
-Cron 表达式,五段式,Unix 用了 50 年:
+Cron expression, 5 fields, used by Unix for 50 years:
```
-分钟 小时 日 月 星期
- * * * * * 每分钟
- 0 9 * * * 每天早上 9:00
- */5 * * * * 每 5 分钟
- 0 9 * * 1-5 工作日早上 9:00
+min hour dom month dow
+ * * * * * Every minute
+ 0 9 * * * Every day at 9:00
+*/5 * * * * Every 5 minutes
+ 0 9 * * 1-5 Weekdays at 9:00
```
-支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。
+Supports `*`, `*/N`, `N`, `N-M`, `N,M,...`.
-### cron_matches: 五段式匹配
+### cron_matches: 5-Field Matching
-标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR):
+Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:
```python
def cron_matches(cron_expr: str, dt: datetime) -> bool:
@@ -105,9 +105,9 @@ def cron_matches(cron_expr: str, dt: datetime) -> bool:
return dom_ok or dow_ok
```
-### 独立调度线程: 每秒轮询
+### Independent Scheduler Thread: 1-Second Polling
-调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:
+The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:
```python
def cron_scheduler_loop():
@@ -130,15 +130,15 @@ def cron_scheduler_loop():
print(f"[cron error] {job.id}: {e}")
```
-关键设计:
-- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间
-- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过
-- **单 job try/except**:一个坏 job 不会拖垮整个调度线程
-- **一次性任务**:触发后自动从 scheduled_jobs 里删除
+Key design:
+- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running
+- **Date-aware minute_marker**: uses `"YYYY-MM-DD HH:MM"` to prevent same-minute double-fire while not skipping on the next day
+- **Per-job try/except**: one bad job doesn't crash the scheduler thread
+- **One-shot jobs**: auto-removed from scheduled_jobs after firing
-### Queue Processor + agent_loop: 交付端
+### Queue Processor + agent_loop: Delivery
-queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:
+The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:
```python
def queue_processor_loop():
@@ -155,7 +155,7 @@ def queue_processor_loop():
agent_lock.release()
```
-agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:
+agent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages:
```python
fired = consume_cron_queue()
@@ -164,11 +164,11 @@ for job in fired:
"content": f"[Scheduled] {job.prompt}"})
```
-生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。
+Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`.
-### 校验:防止坏 cron 杀掉调度器
+### Validation: Prevent Bad Cron from Killing the Scheduler
-`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:
+`schedule_job` validates the cron expression before registering, returning an error for invalid input:
```python
def schedule_job(cron, prompt, recurring=True, durable=True):
@@ -178,127 +178,127 @@ def schedule_job(cron, prompt, recurring=True, durable=True):
# ... register job
```
-从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。
+Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.
### Durable vs Session-only
-- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。
-- **Session-only**:只在内存里。Agent 关闭就没了。
+- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart.
+- **Session-only**: In-memory only. Gone when the agent closes.
-> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。
+> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers "it should fire" and fires. If you need "run even when the app is closed", use system crontab or systemd timer.
-### 合起来跑
+### Putting It Together
```
-1. 启动时:
- load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务
- Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询
- Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付
+1. On startup:
+ load_durable_jobs() → restore durable tasks from .scheduled_tasks.json
+ Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling
+ Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver
-2. 注册任务:
+2. Register a task:
schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
- → CronJob 写入 scheduled_jobs + .scheduled_tasks.json
+ → CronJob written to scheduled_jobs + .scheduled_tasks.json
-3. 每 2 分钟:
- 调度线程检查 → cron_matches 返回 True → cron_queue.append(job)
- → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue
- → 注入 "[Scheduled] run date"
- → LLM 收到消息,执行 date 命令
+3. Every 2 minutes:
+ Scheduler checks → cron_matches returns True → cron_queue.append(job)
+ → queue processor sees idle agent → agent_loop consume_cron_queue
+ → injects "[Scheduled] run date"
+ → LLM receives message, runs date command
-4. 关闭进程:
- 调度线程跟着停(daemon=True)
- .scheduled_tasks.json 还在磁盘上
- 下次启动 → load_durable_jobs → 任务恢复
+4. Process shutdown:
+ Scheduler thread stops (daemon=True)
+ .scheduled_tasks.json stays on disk
+ Next startup → load_durable_jobs → tasks restored
```
---
-## 相对 s13 的变更
+## Changes from s13
-| 组件 | 之前 (s13) | 之后 (s14) |
-|------|-----------|-----------|
-| 触发方式 | 用户手动触发 | 调度线程自动入队 |
-| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
-| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
-| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |
-| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |
-| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |
-| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
+| Component | Before (s13) | After (s14) |
+|-----------|-------------|-------------|
+| Trigger method | User manual trigger | Scheduler thread auto-enqueues |
+| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
+| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
+| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) |
+| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread |
+| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) |
+| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s14_cron_scheduler/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Schedule a task to print the current date every 2 minutes`
2. `List all cron jobs`
3. `Create a one-shot reminder in 1 minute to check the build status`
4. `Cancel the recurring job and verify with list_crons`
-观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`?
+What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`?
---
-## 接下来
+## What's Next
-一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。
+One agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent.
-"重构整个后端",把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。
+"Refactor the entire backend" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team.
-s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。
+s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。
+> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).
-### 一、三个 Cron 工具
+### 1. Three Cron Tools
-CC 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。
+CC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.
-### 二、存储:`.claude/scheduled_tasks.json`
+### 2. Storage: `.claude/scheduled_tasks.json`
```json
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
```
-Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。
+Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.
-### 三、调度器:1 秒轮询
+### 3. Scheduler: 1-Second Polling
-`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。
+`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.
-### 四、Cron 表达式:标准 5 字段
+### 4. Cron Expression: Standard 5 Fields
-分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。
+Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.
-### 五、抖动(防惊群效应)
+### 5. Jitter (Thundering Herd Prevention)
-- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希
-- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发
-- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次
+- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID
+- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`
+- Jitter config adjustable via GrowthBook, refreshed every 60 seconds
-### 六、自动过期
+### 6. Auto-Expiration
-重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。
+Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.
-### 七、作业数上限
+### 7. Job Limit
-`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first."
+`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first."
-### 八、触发注入
+### 8. Trigger Injection
-触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。
+After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.
-### 九、Queue Processor:自动交付
+### 9. Queue Processor: Automatic Delivery
-真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。
+Real CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
diff --git a/s14_cron_scheduler/README.zh.md b/s14_cron_scheduler/README.zh.md
new file mode 100644
index 00000000..997fdb2f
--- /dev/null
+++ b/s14_cron_scheduler/README.zh.md
@@ -0,0 +1,305 @@
+# s14: Cron Scheduler — 按时间表生产工作
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
+> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
+>
+> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。
+
+---
+
+## 问题
+
+闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。
+
+s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。
+
+---
+
+## 解决方案
+
+
+
+教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。
+
+手动 vs 定时:
+
+| | 手动触发 (s13) | 定时触发 (s14) |
+|---|---|---|
+| 触发者 | 用户输入 | 调度线程 |
+| 触发时机 | 随时 | cron 表达式指定 |
+| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |
+| 持久性 | — | durable 跨重启 |
+
+---
+
+## 工作原理
+
+### 四层模型
+
+Cron 调度分四层:
+
+1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有
+2. **Queue**:`cron_queue`,调度线程写入已触发任务
+3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop
+4. **Consumer**:agent_loop 从队列消费,注入到 messages
+
+教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。
+
+### CronJob: 数据结构
+
+每个 cron 任务是一个 `CronJob` 对象:
+
+```python
+@dataclass
+class CronJob:
+ id: str
+ cron: str # "0 9 * * *" (五段式 cron 表达式)
+ prompt: str # 触发时注入给 Agent 的消息
+ recurring: bool # True=周期性,False=一次性
+ durable: bool # True=写磁盘,跨会话保留
+```
+
+Cron 表达式,五段式,Unix 用了 50 年:
+
+```
+分钟 小时 日 月 星期
+ * * * * * 每分钟
+ 0 9 * * * 每天早上 9:00
+ */5 * * * * 每 5 分钟
+ 0 9 * * 1-5 工作日早上 9:00
+```
+
+支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。
+
+### cron_matches: 五段式匹配
+
+标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR):
+
+```python
+def cron_matches(cron_expr: str, dt: datetime) -> bool:
+ fields = cron_expr.strip().split()
+ if len(fields) != 5:
+ return False
+ minute, hour, dom, month, dow = fields
+ dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
+
+ m = _cron_field_matches(minute, dt.minute)
+ h = _cron_field_matches(hour, dt.hour)
+ dom_ok = _cron_field_matches(dom, dt.day)
+ month_ok = _cron_field_matches(month, dt.month)
+ dow_ok = _cron_field_matches(dow, dow_val)
+
+ if not (m and h and month_ok):
+ return False
+ # DOM and DOW: both constrained → either matching is enough (OR)
+ dom_unconstrained = dom == "*"
+ dow_unconstrained = dow == "*"
+ if dom_unconstrained and dow_unconstrained:
+ return True
+ if dom_unconstrained:
+ return dow_ok
+ if dow_unconstrained:
+ return dom_ok
+ return dom_ok or dow_ok
+```
+
+### 独立调度线程: 每秒轮询
+
+调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:
+
+```python
+def cron_scheduler_loop():
+ while True:
+ time.sleep(1)
+ now = datetime.now()
+ minute_marker = now.strftime("%Y-%m-%d %H:%M")
+ with cron_lock:
+ for job in list(scheduled_jobs.values()):
+ try:
+ if cron_matches(job.cron, now):
+ if _last_fired.get(job.id) != minute_marker:
+ cron_queue.append(job)
+ _last_fired[job.id] = minute_marker
+ if not job.recurring:
+ scheduled_jobs.pop(job.id, None)
+ if job.durable:
+ save_durable_jobs()
+ except Exception as e:
+ print(f"[cron error] {job.id}: {e}")
+```
+
+关键设计:
+- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间
+- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过
+- **单 job try/except**:一个坏 job 不会拖垮整个调度线程
+- **一次性任务**:触发后自动从 scheduled_jobs 里删除
+
+### Queue Processor + agent_loop: 交付端
+
+queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:
+
+```python
+def queue_processor_loop():
+ while True:
+ time.sleep(0.2)
+ if not has_cron_queue():
+ continue
+ if not agent_lock.acquire(blocking=False):
+ continue
+ try:
+ if has_cron_queue():
+ run_agent_turn_locked()
+ finally:
+ agent_lock.release()
+```
+
+agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:
+
+```python
+fired = consume_cron_queue()
+for job in fired:
+ messages.append({"role": "user",
+ "content": f"[Scheduled] {job.prompt}"})
+```
+
+生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。
+
+### 校验:防止坏 cron 杀掉调度器
+
+`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:
+
+```python
+def schedule_job(cron, prompt, recurring=True, durable=True):
+ err = validate_cron(cron)
+ if err:
+ return err
+ # ... register job
+```
+
+从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。
+
+### Durable vs Session-only
+
+- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。
+- **Session-only**:只在内存里。Agent 关闭就没了。
+
+> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。
+
+### 合起来跑
+
+```
+1. 启动时:
+ load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务
+ Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询
+ Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付
+
+2. 注册任务:
+ schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
+ → CronJob 写入 scheduled_jobs + .scheduled_tasks.json
+
+3. 每 2 分钟:
+ 调度线程检查 → cron_matches 返回 True → cron_queue.append(job)
+ → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue
+ → 注入 "[Scheduled] run date"
+ → LLM 收到消息,执行 date 命令
+
+4. 关闭进程:
+ 调度线程跟着停(daemon=True)
+ .scheduled_tasks.json 还在磁盘上
+ 下次启动 → load_durable_jobs → 任务恢复
+```
+
+---
+
+## 相对 s13 的变更
+
+| 组件 | 之前 (s13) | 之后 (s14) |
+|------|-----------|-----------|
+| 触发方式 | 用户手动触发 | 调度线程自动入队 |
+| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
+| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
+| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |
+| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |
+| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |
+| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s14_cron_scheduler/code.py
+```
+
+试试这些 prompt:
+
+1. `Schedule a task to print the current date every 2 minutes`
+2. `List all cron jobs`
+3. `Create a one-shot reminder in 1 minute to check the build status`
+4. `Cancel the recurring job and verify with list_crons`
+
+观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`?
+
+---
+
+## 接下来
+
+一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。
+
+"重构整个后端",把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。
+
+s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。
+
+### 一、三个 Cron 工具
+
+CC 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。
+
+### 二、存储:`.claude/scheduled_tasks.json`
+
+```json
+{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
+```
+
+Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。
+
+### 三、调度器:1 秒轮询
+
+`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。
+
+### 四、Cron 表达式:标准 5 字段
+
+分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。
+
+### 五、抖动(防惊群效应)
+
+- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希
+- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发
+- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次
+
+### 六、自动过期
+
+重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。
+
+### 七、作业数上限
+
+`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first."
+
+### 八、触发注入
+
+触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。
+
+### 九、Queue Processor:自动交付
+
+真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。
+
+
+
+
diff --git a/s15_agent_teams/README.en.md b/s15_agent_teams/README.en.md
deleted file mode 100644
index 2f422d55..00000000
--- a/s15_agent_teams/README.en.md
+++ /dev/null
@@ -1,254 +0,0 @@
-# s15: Agent Teams — One Agent Isn't Enough, Form a Team
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20
-> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads.
->
-> **Harness Layer**: Teams — Multi-agent collaboration, message bus.
-
----
-
-## The Problem
-
-"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.
-
-s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.
-
----
-
-## The Solution
-
-
-
-Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).
-
-Sub-agent vs Teammate:
-
-| | s06 Sub-agent | s15 Teammate |
-|---|---|---|
-| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) |
-| Communication | Only returns conclusion | Async inbox, communicate anytime |
-| Context | Fully isolated | Shared via messages |
-| Count | One lead + occasional sub-agent | One Lead + multiple teammates |
-
----
-
-## How It Works
-
-
-
-### MessageBus: File-Based Inboxes
-
-Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):
-
-```python
-class MessageBus:
- def send(self, from_agent: str, to_agent: str,
- content: str, msg_type: str = "message"):
- msg = {"from": from_agent, "to": to_agent,
- "content": content, "type": msg_type,
- "ts": time.time()}
- inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
- with open(inbox, "a") as f:
- f.write(json.dumps(msg) + "\n")
-
- def read_inbox(self, agent: str) -> list[dict]:
- inbox = MAILBOX_DIR / f"{agent}.jsonl"
- if not inbox.exists():
- return []
- msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
- inbox.unlink() # consume: read + delete
- return msgs
-```
-
-Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.
-
-### spawn_teammate_thread: Launching a Teammate
-
-Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:
-
-```python
-def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
- system = f"You are '{name}', a {role}. Use tools to complete tasks."
-
- def run():
- messages = [{"role": "user", "content": prompt}]
- sub_tools = [bash, read_file, write_file, send_message]
- for _ in range(10): # max 10 rounds
- inbox = BUS.read_inbox(name)
- if inbox:
- messages.append({"role": "user",
- "content": f"{json.dumps(inbox)}"})
- response = client.messages.create(
- model=MODEL, system=system, messages=messages[-20:],
- tools=sub_tools, max_tokens=8000)
- # ... execute tools, process results
- # Send final summary to Lead
- BUS.send(name, "lead", summary, "result")
-
- threading.Thread(target=run, daemon=True).start()
-```
-
-Key design:
-- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team
-- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`
-- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox
-
-### Lead's Inbox Injection
-
-Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:
-
-```python
-# After main loop iteration
-inbox = BUS.read_inbox("lead")
-if inbox:
- inbox_text = "\n".join(
- f"From {m['from']}: {m['content'][:200]}" for m in inbox)
- history.append({"role": "user",
- "content": f"[Inbox]\n{inbox_text}"})
-```
-
-Teaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.
-
-### Permission Bubbling
-
-Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):
-
-1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox
-2. Lead's `useInboxPoller` detects the request → routes to approval queue
-3. User approves → Lead sends `permission_response` back to teammate
-4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
-
-### Putting It Together
-
-```
-1. Lead: "Build the backend: one agent isn't enough, form a team"
-2. Lead → spawn_teammate("alice", "backend dev", "Create database schema")
-3. Lead → spawn_teammate("bob", "frontend dev", "Write API client")
-4. Alice thread starts → her own LLM call → bash "python manage.py migrate"
-5. Bob thread starts → his own LLM call → write_file("client.ts", ...)
-6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables")
-7. Bob done → BUS.send("bob", "lead", "Client written with types")
-8. Lead next iteration → inbox injected into history → LLM sees both results
-```
-
-Two teammates work in parallel.
-
----
-
-## Changes from s14
-
-| Component | Before (s14) | After (s15) |
-|-----------|-------------|-------------|
-| Agent count | 1 | 1 Lead + N teammate threads |
-| Communication | None | MessageBus + .mailboxes/*.jsonl |
-| New classes | — | MessageBus, active_teammates dict |
-| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |
-| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
-| Teammate tools | — | bash, read_file, write_file, send_message (4) |
-| Permissions | Local decisions | Teaching code omits (real CC has bubbling) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s15_agent_teams/code.py
-```
-
-Try these prompts:
-
-1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
-2. `Check your inbox for alice's result.`
-3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
-
-What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?
-
----
-
-## What's Next
-
-Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.
-
-s16 Team Protocols → Shutdown handshake and message conventions.
-
-
-Deep Dive into CC Source
-
-> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.
-
-### 1. No Central Message Bus, It's the Filesystem
-
-Teaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files.
-
-Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`
-
-Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.
-
-### 2. 15 Message Types
-
-CC team communication has 15 structured message types (`teammateMailbox.ts`):
-
-| Type | Direction | Purpose |
-|------|-----------|---------|
-| `plain text` | Both ways | Normal inter-teammate communication |
-| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |
-| `permission_request` | Teammate→Lead | Teammate needs operation approval |
-| `permission_response` | Lead→Teammate | Lead's approval result |
-| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |
-| `plan_approval_response` | Lead→Teammate | Lead's plan review |
-| `shutdown_request` | Lead→Teammate | Request graceful shutdown |
-| `shutdown_approved` | Teammate→Lead | Confirm shutdown |
-| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |
-| `task_assignment` | Lead→Teammate | Assign a task |
-| `team_permission_update` | Lead→Teammate | Broadcast permission changes |
-| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |
-| `sandbox_permission_*` | Both ways | Network permission request/reply |
-| `teammate_terminated` | System | Teammate removed notification |
-
-Text messages are wrapped in `` XML tags for delivery to the model.
-
-### 3. Permission Bubbling: Bidirectional Polling
-
-Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`):
-
-1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox
-2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`
-3. Lead's UI shows approval dialog with teammate name and color
-4. User approves → Lead sends `permission_response` back to teammate's inbox
-5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
-
-### 4. Teammate Lifecycle
-
-CC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):
-
-1. **Spawn**: Create tmux pane (or in-process), assign color, write team config
-2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive
-3. **Idle**: Stop hook fires → send `idle_notification` to Lead
-4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up
-
-### 5. Team Config
-
-Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):
-
-```json
-{
- "name": "my-team",
- "leadAgentId": "lead@my-team",
- "members": [{
- "agentId": "researcher@my-team",
- "name": "researcher",
- "agentType": "general-purpose",
- "color": "blue",
- "isActive": true
- }]
-}
-```
-
-Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates").
-
-
-
-
diff --git a/s15_agent_teams/README.ja.md b/s15_agent_teams/README.ja.md
index c4f7c37a..76e2b16a 100644
--- a/s15_agent_teams/README.ja.md
+++ b/s15_agent_teams/README.ja.md
@@ -1,12 +1,14 @@
-# s15: Agent Teams — 一人では無理、チームを組もう
+# s15: Agent Teams — ランタイム実験:永続チームメイト
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20
+s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"一人では無理、チームを組もう"* — ファイル受信箱 + チームメイトスレッド。
>
> **Harness 層**: チーム — マルチ Agent 協調、メッセージバス。
+> **モジュール 1/2:** s15 と s16 は一つの Agent Teams モジュールに含まれる二つの集中実験。この章でランタイムを構築し、s16 はランタイムを繰り返さず型付き協調プロトコルを追加する。
+
---
## 課題
@@ -172,7 +174,7 @@ python s15_agent_teams/code.py
チームメイトは仕事をし、通信できる。しかし、Lead が Alice にシャットダウンを頼む場合、スレッドを強制終了すると書きかけのファイルが残る。丁寧なシャットダウンプロトコルが必要:Lead が shutdown_request を送信、チームメイトは收尾後に終了。
-s16 Team Protocols → シャットダウンハンドシェイクとメッセージの取り決め。
+s16 Agent Teams プロトコル実験 → このランタイムにシャットダウンハンドシェイク、計画承認、型付きリクエスト-返信を追加する。
CC ソースコード深掘り
diff --git a/s15_agent_teams/README.md b/s15_agent_teams/README.md
index 84c75a28..3d035fb2 100644
--- a/s15_agent_teams/README.md
+++ b/s15_agent_teams/README.md
@@ -1,46 +1,48 @@
-# s15: Agent Teams — 一个搞不定,组队来
+# s15: Agent Teams — Runtime Lab: Persistent Teammates
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20
-> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。
+s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
+> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads.
>
-> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。
+> **Harness Layer**: Teams — Multi-agent collaboration, message bus.
+
+> **Module 1 of 2:** s15 and s16 are two focused labs in one Agent Teams module. This lab builds the runtime; s16 adds typed coordination protocols without repeating the runtime.
---
-## 问题
+## The Problem
-"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块。
+"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.
-s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。
+s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.
---
-## 解决方案
+## The Solution
-
+
-教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。
+Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).
-子 Agent vs 队友:
+Sub-agent vs Teammate:
-| | s06 子 Agent | s15 队友 |
+| | s06 Sub-agent | s15 Teammate |
|---|---|---|
-| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop) |
-| 通信 | 只回传结论 | 异步收件箱,随时通信 |
-| 上下文 | 完全隔离 | 通过消息共享信息 |
-| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |
+| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) |
+| Communication | Only returns conclusion | Async inbox, communicate anytime |
+| Context | Fully isolated | Shared via messages |
+| Count | One lead + occasional sub-agent | One Lead + multiple teammates |
---
-## 工作原理
+## How It Works
-
+
-### MessageBus: 文件收件箱
+### MessageBus: File-Based Inboxes
-每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):
+Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):
```python
class MessageBus:
@@ -58,15 +60,15 @@ class MessageBus:
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
- inbox.unlink() # 消费式:读完删除
+ inbox.unlink() # consume: read + delete
return msgs
```
-为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。
+Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.
-### spawn_teammate_thread: 启动队友
+### spawn_teammate_thread: Launching a Teammate
-Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集:
+Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
@@ -75,7 +77,7 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
- for _ in range(10): # 最多 10 轮
+ for _ in range(10): # max 10 rounds
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
@@ -83,24 +85,24 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
- # ... 执行工具、处理结果
- # 完成后发 summary 给 Lead
+ # ... execute tools, process results
+ # Send final summary to Lead
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
```
-关键设计:
-- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的
-- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出
-- **完成后自动汇报**:`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱
+Key design:
+- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team
+- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`
+- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox
-### Lead 的 inbox 注入
+### Lead's Inbox Injection
-Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:
+Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:
```python
-# 主循环结束后
+# After main loop iteration
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
@@ -109,129 +111,129 @@ if inbox:
"content": f"[Inbox]\n{inbox_text}"})
```
-教学版在用户输入循环外注入。CC 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。
+Teaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.
-### 权限冒泡
+### Permission Bubbling
-教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):
+Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):
-1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱
-2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列
-3. 用户审批后 → Lead 发 `permission_response` 回队友
-4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝
+1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox
+2. Lead's `useInboxPoller` detects the request → routes to approval queue
+3. User approves → Lead sends `permission_response` back to teammate
+4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
-### 合起来跑
+### Putting It Together
```
-1. Lead: "搭建后端:一个人搞不定,组队吧"
-2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema")
-3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端")
-4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate"
-5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...)
-6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables")
-7. bob 完成 → BUS.send("bob", "lead", "Client written with types")
-8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果
+1. Lead: "Build the backend: one agent isn't enough, form a team"
+2. Lead → spawn_teammate("alice", "backend dev", "Create database schema")
+3. Lead → spawn_teammate("bob", "frontend dev", "Write API client")
+4. Alice thread starts → her own LLM call → bash "python manage.py migrate"
+5. Bob thread starts → his own LLM call → write_file("client.ts", ...)
+6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables")
+7. Bob done → BUS.send("bob", "lead", "Client written with types")
+8. Lead next iteration → inbox injected into history → LLM sees both results
```
-两个队友并行工作。
+Two teammates work in parallel.
---
-## 相对 s14 的变更
+## Changes from s14
-| 组件 | 之前 (s14) | 之后 (s15) |
-|------|-----------|-----------|
-| Agent 数量 | 1 | 1 Lead + N 队友线程 |
-| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |
-| 新类 | — | MessageBus, active_teammates dict |
-| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
-| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
-| 队友工具 | — | bash, read_file, write_file, send_message (4) |
-| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) |
+| Component | Before (s14) | After (s15) |
+|-----------|-------------|-------------|
+| Agent count | 1 | 1 Lead + N teammate threads |
+| Communication | None | MessageBus + .mailboxes/*.jsonl |
+| New classes | — | MessageBus, active_teammates dict |
+| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |
+| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
+| Teammate tools | — | bash, read_file, write_file, send_message (4) |
+| Permissions | Local decisions | Teaching code omits (real CC has bubbling) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s15_agent_teams/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
-观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?
+What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?
---
-## 接下来
+## What's Next
-队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。
+Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.
-s16 Team Protocols → 关机握手与消息约定。
+s16 Agent Teams Protocol Lab → keep this runtime and add shutdown handshakes, plan approval, and typed request-reply messages.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。
+> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.
-### 一、没有中央消息总线,是文件系统
+### 1. No Central Message Bus, It's the Filesystem
-教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。
+Teaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files.
-收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
+Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`
-写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。
+Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.
-### 二、15 种消息类型
+### 2. 15 Message Types
-CC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`):
+CC team communication has 15 structured message types (`teammateMailbox.ts`):
-| 类型 | 方向 | 用途 |
-|------|------|------|
-| `plain text` | 双向 | 普通队友间通信 |
-| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |
-| `permission_request` | 队友→Lead | 队友需要操作审批 |
-| `permission_response` | Lead→队友 | Lead 审批结果 |
-| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |
-| `plan_approval_response` | Lead→队友 | Lead 审批计划 |
-| `shutdown_request` | Lead→队友 | 请求体面关机 |
-| `shutdown_approved` | 队友→Lead | 确认关机 |
-| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |
-| `task_assignment` | Lead→队友 | 分配任务 |
-| `team_permission_update` | Lead→队友 | 广播权限变更 |
-| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |
-| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |
-| `teammate_terminated` | 系统 | 队友被移除通知 |
+| Type | Direction | Purpose |
+|------|-----------|---------|
+| `plain text` | Both ways | Normal inter-teammate communication |
+| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |
+| `permission_request` | Teammate→Lead | Teammate needs operation approval |
+| `permission_response` | Lead→Teammate | Lead's approval result |
+| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |
+| `plan_approval_response` | Lead→Teammate | Lead's plan review |
+| `shutdown_request` | Lead→Teammate | Request graceful shutdown |
+| `shutdown_approved` | Teammate→Lead | Confirm shutdown |
+| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |
+| `task_assignment` | Lead→Teammate | Assign a task |
+| `team_permission_update` | Lead→Teammate | Broadcast permission changes |
+| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |
+| `sandbox_permission_*` | Both ways | Network permission request/reply |
+| `teammate_terminated` | System | Teammate removed notification |
-文本消息被包装在 `` XML 标签中交付给模型。
+Text messages are wrapped in `` XML tags for delivery to the model.
-### 三、权限冒泡:双向轮询
+### 3. Permission Bubbling: Bidirectional Polling
-教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`):
+Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`):
-1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱
-2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`
-3. Lead 的 UI 显示审批对话框,带队友名字和颜色
-4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱
-5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行
+1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox
+2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`
+3. Lead's UI shows approval dialog with teammate name and color
+4. User approves → Lead sends `permission_response` back to teammate's inbox
+5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
-### 四、队友生命周期
+### 4. Teammate Lifecycle
-CC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建:
+CC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):
-1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config
-2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn
-3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead
-4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理
+1. **Spawn**: Create tmux pane (or in-process), assign color, write team config
+2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive
+3. **Idle**: Stop hook fires → send `idle_notification` to Lead
+4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up
-### 五、Team Config
+### 5. Team Config
-团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):
+Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):
```json
{
@@ -247,7 +249,7 @@ CC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建:
}
```
-队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。
+Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates").
diff --git a/s15_agent_teams/README.zh.md b/s15_agent_teams/README.zh.md
new file mode 100644
index 00000000..e7400999
--- /dev/null
+++ b/s15_agent_teams/README.zh.md
@@ -0,0 +1,256 @@
+# s15: Agent Teams — 运行时实验:持久队友
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
+> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。
+>
+> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。
+
+> **模块 1/2:** s15 与 s16 是同一个 Agent Teams 模块中的两次聚焦实验。本章搭建运行时;s16 在不重复运行时的前提下增加带类型的协作协议。
+
+---
+
+## 问题
+
+"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块。
+
+s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。
+
+---
+
+## 解决方案
+
+
+
+教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。
+
+子 Agent vs 队友:
+
+| | s06 子 Agent | s15 队友 |
+|---|---|---|
+| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop) |
+| 通信 | 只回传结论 | 异步收件箱,随时通信 |
+| 上下文 | 完全隔离 | 通过消息共享信息 |
+| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |
+
+---
+
+## 工作原理
+
+
+
+### MessageBus: 文件收件箱
+
+每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):
+
+```python
+class MessageBus:
+ def send(self, from_agent: str, to_agent: str,
+ content: str, msg_type: str = "message"):
+ msg = {"from": from_agent, "to": to_agent,
+ "content": content, "type": msg_type,
+ "ts": time.time()}
+ inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
+ with open(inbox, "a") as f:
+ f.write(json.dumps(msg) + "\n")
+
+ def read_inbox(self, agent: str) -> list[dict]:
+ inbox = MAILBOX_DIR / f"{agent}.jsonl"
+ if not inbox.exists():
+ return []
+ msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
+ inbox.unlink() # 消费式:读完删除
+ return msgs
+```
+
+为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。
+
+### spawn_teammate_thread: 启动队友
+
+Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集:
+
+```python
+def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
+ system = f"You are '{name}', a {role}. Use tools to complete tasks."
+
+ def run():
+ messages = [{"role": "user", "content": prompt}]
+ sub_tools = [bash, read_file, write_file, send_message]
+ for _ in range(10): # 最多 10 轮
+ inbox = BUS.read_inbox(name)
+ if inbox:
+ messages.append({"role": "user",
+ "content": f"{json.dumps(inbox)}"})
+ response = client.messages.create(
+ model=MODEL, system=system, messages=messages[-20:],
+ tools=sub_tools, max_tokens=8000)
+ # ... 执行工具、处理结果
+ # 完成后发 summary 给 Lead
+ BUS.send(name, "lead", summary, "result")
+
+ threading.Thread(target=run, daemon=True).start()
+```
+
+关键设计:
+- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的
+- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出
+- **完成后自动汇报**:`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱
+
+### Lead 的 inbox 注入
+
+Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:
+
+```python
+# 主循环结束后
+inbox = BUS.read_inbox("lead")
+if inbox:
+ inbox_text = "\n".join(
+ f"From {m['from']}: {m['content'][:200]}" for m in inbox)
+ history.append({"role": "user",
+ "content": f"[Inbox]\n{inbox_text}"})
+```
+
+教学版在用户输入循环外注入。CC 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。
+
+### 权限冒泡
+
+教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):
+
+1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱
+2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列
+3. 用户审批后 → Lead 发 `permission_response` 回队友
+4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝
+
+### 合起来跑
+
+```
+1. Lead: "搭建后端:一个人搞不定,组队吧"
+2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema")
+3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端")
+4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate"
+5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...)
+6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables")
+7. bob 完成 → BUS.send("bob", "lead", "Client written with types")
+8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果
+```
+
+两个队友并行工作。
+
+---
+
+## 相对 s14 的变更
+
+| 组件 | 之前 (s14) | 之后 (s15) |
+|------|-----------|-----------|
+| Agent 数量 | 1 | 1 Lead + N 队友线程 |
+| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |
+| 新类 | — | MessageBus, active_teammates dict |
+| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
+| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
+| 队友工具 | — | bash, read_file, write_file, send_message (4) |
+| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s15_agent_teams/code.py
+```
+
+试试这些 prompt:
+
+1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
+2. `Check your inbox for alice's result.`
+3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
+
+观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?
+
+---
+
+## 接下来
+
+队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。
+
+s16 Agent Teams 协议实验 → 沿用本章运行时,加入关机握手、计划审批与带类型的请求-回复消息。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。
+
+### 一、没有中央消息总线,是文件系统
+
+教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。
+
+收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
+
+写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。
+
+### 二、15 种消息类型
+
+CC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`):
+
+| 类型 | 方向 | 用途 |
+|------|------|------|
+| `plain text` | 双向 | 普通队友间通信 |
+| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |
+| `permission_request` | 队友→Lead | 队友需要操作审批 |
+| `permission_response` | Lead→队友 | Lead 审批结果 |
+| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |
+| `plan_approval_response` | Lead→队友 | Lead 审批计划 |
+| `shutdown_request` | Lead→队友 | 请求体面关机 |
+| `shutdown_approved` | 队友→Lead | 确认关机 |
+| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |
+| `task_assignment` | Lead→队友 | 分配任务 |
+| `team_permission_update` | Lead→队友 | 广播权限变更 |
+| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |
+| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |
+| `teammate_terminated` | 系统 | 队友被移除通知 |
+
+文本消息被包装在 `` XML 标签中交付给模型。
+
+### 三、权限冒泡:双向轮询
+
+教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`):
+
+1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱
+2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`
+3. Lead 的 UI 显示审批对话框,带队友名字和颜色
+4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱
+5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行
+
+### 四、队友生命周期
+
+CC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建:
+
+1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config
+2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn
+3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead
+4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理
+
+### 五、Team Config
+
+团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):
+
+```json
+{
+ "name": "my-team",
+ "leadAgentId": "lead@my-team",
+ "members": [{
+ "agentId": "researcher@my-team",
+ "name": "researcher",
+ "agentType": "general-purpose",
+ "color": "blue",
+ "isActive": true
+ }]
+}
+```
+
+队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。
+
+
+
+
diff --git a/s16_team_protocols/README.en.md b/s16_team_protocols/README.en.md
deleted file mode 100644
index 45cfb310..00000000
--- a/s16_team_protocols/README.en.md
+++ /dev/null
@@ -1,241 +0,0 @@
-# s16: Team Protocols — Teammates Need Agreements
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20
-> *"Teammates need agreements"* — request-response pattern drives all negotiation.
->
-> **Harness Layer**: Protocols — Structured handshakes between agents.
-
----
-
-## The Problem
-
-s15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap:
-
-**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up.
-
-**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds.
-
-Both scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected.
-
----
-
-## The Solution
-
-
-
-Teaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation).
-
-Two protocols, one mechanism:
-
-| Protocol | Direction | Purpose |
-|----------|-----------|---------|
-| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake |
-| plan_approval_request / response | Teammate → Lead | Plan approval protocol example |
-
-> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real CC has a permission gating mechanism for teammates.
-
----
-
-## How It Works
-
-### ProtocolState: Request State
-
-Each protocol request creates a state record tracking who sent it, to whom, current status, and payload:
-
-```python
-@dataclass
-class ProtocolState:
- request_id: str # Unique ID, e.g. "req_004281"
- type: str # "shutdown" | "plan_approval"
- sender: str # Sender
- target: str # Recipient
- status: str # pending | approved | rejected
- payload: str # Plan text or shutdown reason
- created_at: float # Timestamp
-
-pending_requests: dict[str, ProtocolState] = {}
-```
-
-A record is created when sending a request, found via `request_id` when receiving a response, and its status updated.
-
-### Four-Step Protocol Flow
-
-Using shutdown as an example, the full chain:
-
-```
-1. Lead sends request
- req_id = new_request_id() # "req_004281"
- pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
- BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
-
-2. Teammate receives → dispatch
- inbox = BUS.read_inbox("alice")
- msg_type = msg["type"] # "shutdown_request"
- → routed to handle_shutdown_request()
-
-3. Teammate replies
- BUS.send("alice", "lead", "shutdown_response",
- metadata={"request_id": req_id, "approve": True})
-
-4. Lead receives response → match
- match_response("shutdown_response", req_id, approve=True)
- pending_requests[req_id].status = "approved"
-```
-
-`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back.
-
-### dispatch_message: Route by Type
-
-A teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type:
-
-```python
-def handle_inbox_message(name, msg, messages):
- msg_type = msg.get("type", "message")
- req_id = msg.get("metadata", {}).get("request_id", "")
-
- if msg_type == "shutdown_request":
- BUS.send(name, "lead", "Shutting down.", "shutdown_response",
- {"request_id": req_id, "approve": True})
- return True # Stop the loop
-
- if msg_type == "plan_approval_response":
- approve = msg["metadata"].get("approve", False)
- messages.append({"role": "user",
- "content": "[Plan approved]" if approve else "[Plan rejected]"})
- return False # Continue
-```
-
-Adding a new protocol type means adding a new `if` branch.
-
-### match_response: Type Validation
-
-`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type:
-
-```python
-def match_response(response_type, request_id, approve):
- state = pending_requests.get(request_id)
- if not state:
- return
- if state.type == "shutdown" and response_type != "shutdown_response":
- return # type mismatch, skip
- if state.type == "plan_approval" and response_type != "plan_approval_response":
- return
- if state.status != "pending":
- return # already resolved, skip duplicate
- state.status = "approved" if approve else "rejected"
-```
-
-A shutdown_response cannot accidentally approve a plan_approval request.
-
-### Unified Inbox Consumer: consume_lead_inbox
-
-Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates:
-
-```python
-def consume_lead_inbox(route_protocol=True) -> list[dict]:
- msgs = BUS.read_inbox("lead")
- if route_protocol:
- for msg in msgs:
- meta = msg.get("metadata", {})
- req_id = meta.get("request_id", "")
- msg_type = msg.get("type", "")
- if req_id and msg_type.endswith("_response"):
- match_response(msg_type, req_id, meta.get("approve", False))
- return msgs
-```
-
-The main loop also injects inbox messages into `history` so the LLM can see and react to them.
-
-### Teammate Idle Loop: Wait Instead of Exit
-
-s15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages.
-
-```
-LLM returns non-tool_use
- → idle: poll inbox every second
- → receives shutdown_request → reply shutdown_response → exit
- → receives new message → inject into messages → continue LLM turn
-```
-
-Teaching version omits idle_notification to Lead. Real CC sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks.
-
-### Putting It Together
-
-```
-1. Lead: "Have Alice create a file, then shut her down"
-2. Lead → spawn_teammate("alice", "backend", "Create config.py")
-3. alice thread starts → write_file("config.py", "...") → done → idle
-4. Lead → request_shutdown("alice")
- → BUS.send("shutdown_request", {request_id: "req_000142"})
-5. alice idle poll receives → handle_shutdown_request
- → BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
-6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
- → pending_requests["req_000142"].status = "approved"
- → inbox message injected into history, LLM sees shutdown result
-```
-
-Shutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`.
-
----
-
-## Changes from s15
-
-| Component | Before (s15) | After (s16) |
-|-----------|-------------|-------------|
-| Coordination | Loose text messages | Structured request-response protocol |
-| Request tracking | None | ProtocolState + pending_requests dict |
-| Message routing | All treated as text | dispatch_message routes by type |
-| Shutdown | Natural exit or kill thread | request_id handshake mechanism |
-| Plan approval | None | Message flow example (no execution gating) |
-| New message types | message, result | + shutdown_request/response, plan_approval_request/response |
-| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) |
-| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox |
-| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) |
-| Teammate tools | 4 (s15) | + submit_plan (5) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s16_team_protocols/code.py
-```
-
-Try these prompts:
-
-1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
-2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
-
-What to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request?
-
----
-
-## What's Next
-
-In s15-s16, Lead must assign tasks to each teammate. "Alice does this, Bob does that." With 10 unclaimed tasks on the board, Lead has to manually assign each one.
-
-What if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own.
-
-s17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.
-
-
-Deep Dive into CC Source
-
-CC's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences:
-
-**Shutdown protocol**: CC's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types.
-
-**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. "approved but run in plan mode"), response can include `feedback` string for teammate to revise and resubmit. Not a simple "Lead manually uses review_plan tool" flow.
-
-**Message format**: CC's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`).
-
-**Execution gating**: CC's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception.
-
-**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. CC's protocol messages all share the same request id correlation mechanism.
-
-
-
-
diff --git a/s16_team_protocols/README.ja.md b/s16_team_protocols/README.ja.md
index 8df8c30d..e89eb302 100644
--- a/s16_team_protocols/README.ja.md
+++ b/s16_team_protocols/README.ja.md
@@ -1,12 +1,14 @@
-# s16: Team Protocols — チームメイト間には取り決めが必要
+# s16: Agent Teams — プロトコル実験:型付き協調
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20
+s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
> *"チームメイト間には取り決めが必要"* — request-response パターンが全てのネゴシエーションを駆動。
>
> **Harness 層**: プロトコル — Agent 間の構造化ハンドシェイク。
+> **モジュール 2/2:** s15 でチームメイトランタイムとメールボックスを構築した。この章で変えるのは協調契約だけであり、リクエスト、返信、相関 ID、状態遷移を扱う。
+
---
## 課題
diff --git a/s16_team_protocols/README.md b/s16_team_protocols/README.md
index d96190fe..7713a538 100644
--- a/s16_team_protocols/README.md
+++ b/s16_team_protocols/README.md
@@ -1,96 +1,96 @@
-# s16: Team Protocols — 队友之间要有约定
+# s16: Agent Teams — Protocol Lab: Typed Coordination
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20
-> *"队友之间要有约定"* — request-response 模式驱动协商。
+s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
+> *"Teammates need agreements"* — request-response pattern drives all negotiation.
>
-> **Harness 层**: 协议 — Agent 之间的结构化握手。
+> **Harness Layer**: Protocols — Structured handshakes between agents.
+
+> **Module 2 of 2:** s15 built the teammate runtime and mailbox. This lab changes only the coordination contract: requests, replies, correlation IDs, and state transitions.
---
-## 问题
+## The Problem
-s15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题:
+s15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap:
-**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。
+**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up.
-**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。
+**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds.
-这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。
+Both scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected.
---
-## 解决方案
+## The Solution
-
+
-教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。
+Teaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation).
-两种协议,一套机制:
+Two protocols, one mechanism:
-| 协议 | 方向 | 用途 |
-|------|------|------|
-| shutdown_request / response | Lead → 队友 | 体面关机握手 |
-| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 |
+| Protocol | Direction | Purpose |
+|----------|-----------|---------|
+| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake |
+| plan_approval_request / response | Teammate → Lead | Plan approval protocol example |
-> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 CC 的队友有 permission gating 机制。
+> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real CC has a permission gating mechanism for teammates.
---
-## 工作原理
+## How It Works
-### ProtocolState: 请求状态
+### ProtocolState: Request State
-每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容:
+Each protocol request creates a state record tracking who sent it, to whom, current status, and payload:
```python
@dataclass
class ProtocolState:
- request_id: str # 唯一 ID,如 "req_004281"
+ request_id: str # Unique ID, e.g. "req_004281"
type: str # "shutdown" | "plan_approval"
- sender: str # 发起方
- target: str # 接收方
+ sender: str # Sender
+ target: str # Recipient
status: str # pending | approved | rejected
- payload: str # 计划文本或关机原因
- created_at: float # 时间戳
+ payload: str # Plan text or shutdown reason
+ created_at: float # Timestamp
pending_requests: dict[str, ProtocolState] = {}
```
-发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。
+A record is created when sending a request, found via `request_id` when receiving a response, and its status updated.
-### 四步协议流程
+### Four-Step Protocol Flow
-以关机为例,完整链路:
+Using shutdown as an example, the full chain:
```
-① Lead 发请求
+1. Lead sends request
req_id = new_request_id() # "req_004281"
pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
-② 队友收到 → dispatch
+2. Teammate receives → dispatch
inbox = BUS.read_inbox("alice")
msg_type = msg["type"] # "shutdown_request"
- → 路由到 handle_shutdown_request()
+ → routed to handle_shutdown_request()
-③ 队友回复
+3. Teammate replies
BUS.send("alice", "lead", "shutdown_response",
metadata={"request_id": req_id, "approve": True})
-④ Lead 收响应 → match
+4. Lead receives response → match
match_response("shutdown_response", req_id, approve=True)
pending_requests[req_id].status = "approved"
```
-`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。
+`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back.
-> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。
+### dispatch_message: Route by Type
-### dispatch_message: 按类型路由
-
-队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发:
+A teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type:
```python
def handle_inbox_message(name, msg, messages):
@@ -100,20 +100,20 @@ def handle_inbox_message(name, msg, messages):
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down.", "shutdown_response",
{"request_id": req_id, "approve": True})
- return True # 停止循环
+ return True # Stop the loop
if msg_type == "plan_approval_response":
approve = msg["metadata"].get("approve", False)
messages.append({"role": "user",
"content": "[Plan approved]" if approve else "[Plan rejected]"})
- return False # 继续循环
+ return False # Continue
```
-新增协议类型只需加新的 `if` 分支。
+Adding a new protocol type means adding a new `if` branch.
-### match_response: 类型校验
+### match_response: Type Validation
-`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型:
+`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type:
```python
def match_response(response_type, request_id, approve):
@@ -129,11 +129,11 @@ def match_response(response_type, request_id, approve):
state.status = "approved" if approve else "rejected"
```
-一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。
+A shutdown_response cannot accidentally approve a plan_approval request.
-### 统一 inbox 消费:consume_lead_inbox
+### Unified Inbox Consumer: consume_lead_inbox
-`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新:
+Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates:
```python
def consume_lead_inbox(route_protocol=True) -> list[dict]:
@@ -148,95 +148,95 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]:
return msgs
```
-主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。
+The main loop also injects inbox messages into `history` so the LLM can see and react to them.
-### 队友 idle loop:等待而不是退出
+### Teammate Idle Loop: Wait Instead of Exit
-s15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。
+s15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages.
```
-LLM 返回非 tool_use
- → idle: 每秒轮询 inbox
- → 收到 shutdown_request → 回复 shutdown_response → 退出
- → 收到新消息 → 注入 messages → 继续 LLM turn
+LLM returns non-tool_use
+ → idle: poll inbox every second
+ → receives shutdown_request → reply shutdown_response → exit
+ → receives new message → inject into messages → continue LLM turn
```
-教学版省略了 idle_notification 给 Lead 的通知。真实 CC 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。
+Teaching version omits idle_notification to Lead. Real CC sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks.
-### 合起来跑
+### Putting It Together
```
-1. Lead: "让 Alice 创建一个文件,然后关机"
-2. Lead → spawn_teammate("alice", "backend", "创建 config.py")
-3. alice 线程启动 → write_file("config.py", "...") → 完成 → idle
+1. Lead: "Have Alice create a file, then shut her down"
+2. Lead → spawn_teammate("alice", "backend", "Create config.py")
+3. alice thread starts → write_file("config.py", "...") → done → idle
4. Lead → request_shutdown("alice")
→ BUS.send("shutdown_request", {request_id: "req_000142"})
-5. alice idle 轮询收到 → handle_shutdown_request
+5. alice idle poll receives → handle_shutdown_request
→ BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
→ pending_requests["req_000142"].status = "approved"
- → inbox 消息注入 history,LLM 看到关机结果
+ → inbox message injected into history, LLM sees shutdown result
```
-关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。
+Shutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`.
---
-## 相对 s15 的变更
+## Changes from s15
-| 组件 | 之前 (s15) | 之后 (s16) |
-|------|-----------|-----------|
-| 协调方式 | 松散文本消息 | 结构化请求-响应协议 |
-| 请求追踪 | 无 | ProtocolState + pending_requests dict |
-| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 |
-| 关机 | 自然退出或杀线程 | request_id 握手机制 |
-| 计划审批 | 无 | 消息流程示例(未实现执行门控) |
-| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response |
-| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) |
-| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox |
-| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) |
-| 队友工具 | 4 (s15) | + submit_plan (5) |
+| Component | Before (s15) | After (s16) |
+|-----------|-------------|-------------|
+| Coordination | Loose text messages | Structured request-response protocol |
+| Request tracking | None | ProtocolState + pending_requests dict |
+| Message routing | All treated as text | dispatch_message routes by type |
+| Shutdown | Natural exit or kill thread | request_id handshake mechanism |
+| Plan approval | None | Message flow example (no execution gating) |
+| New message types | message, result | + shutdown_request/response, plan_approval_request/response |
+| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) |
+| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox |
+| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) |
+| Teammate tools | 4 (s15) | + submit_plan (5) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s16_team_protocols/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
-观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request?
+What to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request?
---
-## 接下来
+## What's Next
-s15-s16 中,Lead 必须给每个队友分配任务。"Alice 做这个,Bob 做那个"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。
+In s15-s16, Lead must assign tasks to each teammate. "Alice does this, Bob does that." With 10 unclaimed tasks on the board, Lead has to manually assign each one.
-能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。
+What if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own.
-s17 Autonomous Agents → 队友自组织,不需要领导分配。
+s17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.
-深入 CC 源码
+Deep Dive into CC Source
-CC 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于:
+CC's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences:
-**关机协议**:CC 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。
+**Shutdown protocol**: CC's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types.
-**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如"批准但以 plan mode 运行"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的"Lead 手动 review_plan 工具"流程。
+**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. "approved but run in plan mode"), response can include `feedback` string for teammate to revise and resubmit. Not a simple "Lead manually uses review_plan tool" flow.
-**消息格式**:CC 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。
+**Message format**: CC's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`).
-**执行门控**:CC 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。
+**Execution gating**: CC's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception.
-**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。CC 的所有协议消息共用同一个 request id 关联机制。
+**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. CC's protocol messages all share the same request id correlation mechanism.
diff --git a/s16_team_protocols/README.zh.md b/s16_team_protocols/README.zh.md
new file mode 100644
index 00000000..8218927f
--- /dev/null
+++ b/s16_team_protocols/README.zh.md
@@ -0,0 +1,245 @@
+# s16: Agent Teams — 协议实验:带类型的协作
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
+> *"队友之间要有约定"* — request-response 模式驱动协商。
+>
+> **Harness 层**: 协议 — Agent 之间的结构化握手。
+
+> **模块 2/2:** s15 已经搭好队友运行时与邮箱。本章只改变协作契约:请求、回复、关联 ID 和状态迁移。
+
+---
+
+## 问题
+
+s15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题:
+
+**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。
+
+**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。
+
+这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。
+
+---
+
+## 解决方案
+
+
+
+教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。
+
+两种协议,一套机制:
+
+| 协议 | 方向 | 用途 |
+|------|------|------|
+| shutdown_request / response | Lead → 队友 | 体面关机握手 |
+| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 |
+
+> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 CC 的队友有 permission gating 机制。
+
+---
+
+## 工作原理
+
+### ProtocolState: 请求状态
+
+每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容:
+
+```python
+@dataclass
+class ProtocolState:
+ request_id: str # 唯一 ID,如 "req_004281"
+ type: str # "shutdown" | "plan_approval"
+ sender: str # 发起方
+ target: str # 接收方
+ status: str # pending | approved | rejected
+ payload: str # 计划文本或关机原因
+ created_at: float # 时间戳
+
+pending_requests: dict[str, ProtocolState] = {}
+```
+
+发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。
+
+### 四步协议流程
+
+以关机为例,完整链路:
+
+```
+① Lead 发请求
+ req_id = new_request_id() # "req_004281"
+ pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
+ BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
+
+② 队友收到 → dispatch
+ inbox = BUS.read_inbox("alice")
+ msg_type = msg["type"] # "shutdown_request"
+ → 路由到 handle_shutdown_request()
+
+③ 队友回复
+ BUS.send("alice", "lead", "shutdown_response",
+ metadata={"request_id": req_id, "approve": True})
+
+④ Lead 收响应 → match
+ match_response("shutdown_response", req_id, approve=True)
+ pending_requests[req_id].status = "approved"
+```
+
+`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。
+
+> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。
+
+### dispatch_message: 按类型路由
+
+队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发:
+
+```python
+def handle_inbox_message(name, msg, messages):
+ msg_type = msg.get("type", "message")
+ req_id = msg.get("metadata", {}).get("request_id", "")
+
+ if msg_type == "shutdown_request":
+ BUS.send(name, "lead", "Shutting down.", "shutdown_response",
+ {"request_id": req_id, "approve": True})
+ return True # 停止循环
+
+ if msg_type == "plan_approval_response":
+ approve = msg["metadata"].get("approve", False)
+ messages.append({"role": "user",
+ "content": "[Plan approved]" if approve else "[Plan rejected]"})
+ return False # 继续循环
+```
+
+新增协议类型只需加新的 `if` 分支。
+
+### match_response: 类型校验
+
+`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型:
+
+```python
+def match_response(response_type, request_id, approve):
+ state = pending_requests.get(request_id)
+ if not state:
+ return
+ if state.type == "shutdown" and response_type != "shutdown_response":
+ return # type mismatch, skip
+ if state.type == "plan_approval" and response_type != "plan_approval_response":
+ return
+ if state.status != "pending":
+ return # already resolved, skip duplicate
+ state.status = "approved" if approve else "rejected"
+```
+
+一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。
+
+### 统一 inbox 消费:consume_lead_inbox
+
+`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新:
+
+```python
+def consume_lead_inbox(route_protocol=True) -> list[dict]:
+ msgs = BUS.read_inbox("lead")
+ if route_protocol:
+ for msg in msgs:
+ meta = msg.get("metadata", {})
+ req_id = meta.get("request_id", "")
+ msg_type = msg.get("type", "")
+ if req_id and msg_type.endswith("_response"):
+ match_response(msg_type, req_id, meta.get("approve", False))
+ return msgs
+```
+
+主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。
+
+### 队友 idle loop:等待而不是退出
+
+s15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。
+
+```
+LLM 返回非 tool_use
+ → idle: 每秒轮询 inbox
+ → 收到 shutdown_request → 回复 shutdown_response → 退出
+ → 收到新消息 → 注入 messages → 继续 LLM turn
+```
+
+教学版省略了 idle_notification 给 Lead 的通知。真实 CC 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。
+
+### 合起来跑
+
+```
+1. Lead: "让 Alice 创建一个文件,然后关机"
+2. Lead → spawn_teammate("alice", "backend", "创建 config.py")
+3. alice 线程启动 → write_file("config.py", "...") → 完成 → idle
+4. Lead → request_shutdown("alice")
+ → BUS.send("shutdown_request", {request_id: "req_000142"})
+5. alice idle 轮询收到 → handle_shutdown_request
+ → BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
+6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
+ → pending_requests["req_000142"].status = "approved"
+ → inbox 消息注入 history,LLM 看到关机结果
+```
+
+关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。
+
+---
+
+## 相对 s15 的变更
+
+| 组件 | 之前 (s15) | 之后 (s16) |
+|------|-----------|-----------|
+| 协调方式 | 松散文本消息 | 结构化请求-响应协议 |
+| 请求追踪 | 无 | ProtocolState + pending_requests dict |
+| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 |
+| 关机 | 自然退出或杀线程 | request_id 握手机制 |
+| 计划审批 | 无 | 消息流程示例(未实现执行门控) |
+| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response |
+| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) |
+| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox |
+| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) |
+| 队友工具 | 4 (s15) | + submit_plan (5) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s16_team_protocols/code.py
+```
+
+试试这些 prompt:
+
+1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
+2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
+
+观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request?
+
+---
+
+## 接下来
+
+s15-s16 中,Lead 必须给每个队友分配任务。"Alice 做这个,Bob 做那个"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。
+
+能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。
+
+s17 Autonomous Agents → 队友自组织,不需要领导分配。
+
+
+深入 CC 源码
+
+CC 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于:
+
+**关机协议**:CC 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。
+
+**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如"批准但以 plan mode 运行"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的"Lead 手动 review_plan 工具"流程。
+
+**消息格式**:CC 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。
+
+**执行门控**:CC 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。
+
+**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。CC 的所有协议消息共用同一个 request id 关联机制。
+
+
+
+
diff --git a/s17_autonomous_agents/README.en.md b/s17_autonomous_agents/README.en.md
deleted file mode 100644
index d229194b..00000000
--- a/s17_autonomous_agents/README.en.md
+++ /dev/null
@@ -1,271 +0,0 @@
-# s17: Autonomous Agents — Check the Board, Claim the Task
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
-
-> *"Check the board, claim the task"* — poll when idle, work when found.
->
-> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
-
----
-
-## The Problem
-
-s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
-
----
-
-## The Solution
-
-
-
-Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
-
-Teammate lifecycle expands from two phases to three:
-
-| Phase | Behavior | Exit condition |
-|-------|----------|----------------|
-| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
-| IDLE | 5s poll inbox + task board | 60s timeout |
-| SHUTDOWN | Send summary, exit | — |
-
----
-
-## How It Works
-
-### idle_poll: Idle Polling
-
-After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
-
-```python
-IDLE_POLL_INTERVAL = 5 # seconds
-IDLE_TIMEOUT = 60 # seconds
-
-def idle_poll(name, messages, role) -> str:
- """Return 'work', 'shutdown', or 'timeout'."""
- for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
- time.sleep(IDLE_POLL_INTERVAL)
-
- # ① Check inbox (priority)
- inbox = BUS.read_inbox(name)
- if inbox:
- # shutdown_request handled immediately
- for msg in inbox:
- if msg.get("type") == "shutdown_request":
- # ... reply shutdown_response
- return "shutdown"
- # Regular messages: inject into context, return to WORK
- messages.append(...)
- return "work"
-
- # ② Scan task board
- unclaimed = scan_unclaimed_tasks()
- if unclaimed:
- task = unclaimed[0]
- result = claim_task(task["id"], name)
- if "Claimed" in result:
- messages.append(...)
- return "work"
- return "timeout"
-```
-
-Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
-
-### scan_unclaimed_tasks: Scan the Task Board
-
-Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
-
-```python
-def scan_unclaimed_tasks() -> list[dict]:
- unclaimed = []
- for f in sorted(TASKS_DIR.glob("task_*.json")):
- task = json.loads(f.read_text())
- if (task.get("status") == "pending"
- and not task.get("owner")
- and can_start(task["id"])):
- unclaimed.append(task)
- return unclaimed
-```
-
-Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
-
-### claim_task: Owner Check
-
-Auto-claim checks the claim result, not treating failure as success:
-
-```python
-def claim_task(task_id: str, owner: str = "agent") -> str:
- task = load_task(task_id)
- if task.status != "pending":
- return f"Task {task_id} is {task.status}, cannot claim"
- if task.owner:
- return f"Task {task_id} already owned by {task.owner}"
- if not can_start(task_id):
- return f"Blocked by: {deps}"
- task.owner = owner
- task.status = "in_progress"
- save_task(task)
- return f"Claimed {task.id} ({task.subject})"
-```
-
-Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
-
-### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
-
-s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
-
-```python
-# Outer loop: WORK → IDLE cycle
-while True:
- # WORK phase: inner loop (max 10 LLM rounds)
- for _ in range(10):
- # Check inbox, dispatch protocol, call LLM, execute tools
- ...
- if response.stop_reason != "tool_use":
- break # WORK phase ends
-
- # IDLE phase
- idle_result = idle_poll(name, messages, role)
- if idle_result == "shutdown":
- break
- if idle_result == "timeout":
- break # 60s timeout → SHUTDOWN
-
-# SHUTDOWN: send summary to Lead
-BUS.send(name, "lead", summary, "result")
-```
-
-Key design:
-- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
-- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
-- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
-- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
-
-### Identity Re-injection
-
-After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
-
-```python
-if len(messages) <= 3:
- messages.insert(0, {"role": "user",
- "content": f"You are '{name}', role: {role}. "
- f"Continue your work."})
-```
-
-Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
-
-### consume_lead_inbox: Unified Inbox Consumer
-
-Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
-
-### Putting It Together
-
-```
-1. Lead: "Build the backend — too many tasks, let teammates self-claim"
-2. Lead → create_task("Create database schema")
-3. Lead → create_task("Write API routes")
-4. Lead → create_task("Write unit tests")
-5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
-6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
-
-7. alice thread starts → WORK: no initial inbox → spins → IDLE
-8. bob thread starts → WORK: no initial inbox → spins → IDLE
-
-9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
-10. alice → claim_task → "Create database schema" → back to WORK
-11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
-12. bob → claim_task → "Write API routes" → back to WORK
-
-13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
-14. alice IDLE → scan → "Write unit tests" → claim → WORK
-15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
-16. alice IDLE → 60s no new tasks → SHUTDOWN
-
-17. bob similar flow → done → SHUTDOWN
-18. Lead consume_lead_inbox → sees alice and bob's summaries
-```
-
-Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
-
----
-
-## Changes from s16
-
-| Component | Before (s16) | After (s17) |
-|-----------|-------------|-------------|
-| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
-| Teammate state | WORK → IDLE (1s inbox poll) → WORK / SHUTDOWN | WORK → IDLE (5s inbox + task board poll, 60s timeout) → WORK / SHUTDOWN |
-| claim_task | No owner check | Rejects tasks that already have an owner |
-| IDLE phase shutdown | Exits after receiving shutdown_request | Dispatches shutdown immediately and exits |
-| Lead inbox | consume_lead_inbox routes protocol responses and injects into context | Reuses consume_lead_inbox mechanism |
-| New functions | consume_lead_inbox already exists | idle_poll, scan_unclaimed_tasks (reuses consume_lead_inbox) |
-| Identity persistence | System prompt only | Auto re-inject after compression |
-| Lead tools | 14 | 14 (unchanged) |
-| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
-| Teammate exit | WORK ends → enters IDLE, waits for shutdown_request (no timeout) | Exits after 60s idle timeout or receiving shutdown_request |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s17_autonomous_agents/code.py
-```
-
-Try this prompt:
-
-`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
-
-What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
-
----
-
-## What's Next
-
-Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
-
-s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
-
-
-Deep Dive into CC Source
-
-> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
-
-### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
-
-Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
-
-**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
-
-**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
-
-**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
-
-**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
-
-### 2. Task Claiming: File Locks + Atomic Operations
-
-`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
-
-`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
-
-### 3. Teaching Version vs CC Comparison
-
-| Dimension | Teaching (s17) | CC |
-|-----------|----------------|-----|
-| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
-| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
-| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
-| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
-| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
-| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
-| Identity persistence | Messages length detection | Context compaction preserves system prompt |
-| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
-
-Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
-
-
-
-
diff --git a/s17_autonomous_agents/README.ja.md b/s17_autonomous_agents/README.ja.md
index 14177872..3762fc22 100644
--- a/s17_autonomous_agents/README.ja.md
+++ b/s17_autonomous_agents/README.ja.md
@@ -1,13 +1,15 @@
# s17: Autonomous Agents — ボードを見て、自分で認領
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
+s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"ボードを見て、自分で認領"* — 空き時にポーリング、仕事があれば開始。
>
> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。
+> **コアと任意項目:** アイドル時の仕事発見と原子的な claim が本章の中心。アイデンティティ再注入は教育版の高度な補助で、初回は読み飛ばしてよい。
+
---
## 課題
@@ -142,7 +144,7 @@ BUS.send(name, "lead", summary, "result")
- **IDLE タイムアウト 60 秒**:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了
- **shutdown_request は両フェーズで応答**:WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信
-### 身份再注入
+### 発展(任意):アイデンティティ再注入
autoCompact(s08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:
diff --git a/s17_autonomous_agents/README.md b/s17_autonomous_agents/README.md
index 0370cdbc..7d46633e 100644
--- a/s17_autonomous_agents/README.md
+++ b/s17_autonomous_agents/README.md
@@ -1,42 +1,44 @@
-# s17: Autonomous Agents — 自己看板,自己认领
+# s17: Autonomous Agents — Check the Board, Claim the Task
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
+s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
-> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
+> *"Check the board, claim the task"* — poll when idle, work when found.
>
-> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
+> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
+
+> **Core vs optional:** idle work discovery and atomic claiming are the lesson's core. Identity re-injection is an advanced teaching workaround and may be skipped on a first pass.
---
-## 问题
+## The Problem
-s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
+s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
---
-## 解决方案
+## The Solution
-
+
-沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。
+Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
-队友生命周期从两阶段变成三阶段:
+Teammate lifecycle expands from two phases to three:
-| 阶段 | 行为 | 退出条件 |
-|------|------|---------|
-| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
-| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |
-| SHUTDOWN | 发 summary,退出 | — |
+| Phase | Behavior | Exit condition |
+|-------|----------|----------------|
+| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
+| IDLE | 5s poll inbox + task board | 60s timeout |
+| SHUTDOWN | Send summary, exit | — |
---
-## 工作原理
+## How It Works
-### idle_poll: 空闲轮询
+### idle_poll: Idle Polling
-队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
+After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
```python
IDLE_POLL_INTERVAL = 5 # seconds
@@ -47,19 +49,19 @@ def idle_poll(name, messages, role) -> str:
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
- # ① 检查收件箱(优先)
+ # ① Check inbox (priority)
inbox = BUS.read_inbox(name)
if inbox:
- # shutdown_request 立即处理
+ # shutdown_request handled immediately
for msg in inbox:
if msg.get("type") == "shutdown_request":
- # ... 回复 shutdown_response
+ # ... reply shutdown_response
return "shutdown"
- # 普通消息注入上下文,回到 WORK
+ # Regular messages: inject into context, return to WORK
messages.append(...)
return "work"
- # ② 扫描任务看板
+ # ② Scan task board
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
@@ -70,11 +72,11 @@ def idle_poll(name, messages, role) -> str:
return "timeout"
```
-inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。
+Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
-### scan_unclaimed_tasks: 扫描任务看板
+### scan_unclaimed_tasks: Scan the Task Board
-找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:
+Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
```python
def scan_unclaimed_tasks() -> list[dict]:
@@ -88,11 +90,11 @@ def scan_unclaimed_tasks() -> list[dict]:
return unclaimed
```
-三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。
+Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
-### claim_task: owner 检查
+### claim_task: Owner Check
-自动认领时检查 claim 结果,不把失败当成功:
+Auto-claim checks the claim result, not treating failure as success:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
@@ -109,42 +111,42 @@ def claim_task(task_id: str, owner: str = "agent") -> str:
return f"Claimed {task.id} ({task.subject})"
```
-教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
+Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
-### 队友生命周期: WORK → IDLE → SHUTDOWN
+### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
-s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:
+s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
```python
# Outer loop: WORK → IDLE cycle
while True:
- # WORK phase: 内层循环(最多 10 轮 LLM 调用)
+ # WORK phase: inner loop (max 10 LLM rounds)
for _ in range(10):
- # 检查 inbox、处理协议消息、调 LLM、执行工具
+ # Check inbox, dispatch protocol, call LLM, execute tools
...
if response.stop_reason != "tool_use":
- break # WORK 阶段结束
+ break # WORK phase ends
# IDLE phase
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
- break # 60s 超时 → SHUTDOWN
+ break # 60s timeout → SHUTDOWN
-# SHUTDOWN: 发 summary 给 Lead
+# SHUTDOWN: send summary to Lead
BUS.send(name, "lead", summary, "result")
```
-关键设计:
-- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求
-- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环)
-- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出
-- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复
+Key design:
+- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
+- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
+- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
+- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
-### 身份重注入
+### Advanced (Optional): Identity Re-injection
-autoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
+After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
```python
if len(messages) <= 3:
@@ -153,118 +155,118 @@ if len(messages) <= 3:
f"Continue your work."})
```
-消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。
+Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
-### consume_lead_inbox: 统一 inbox 消费
+### consume_lead_inbox: Unified Inbox Consumer
-`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。
+Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
-### 合起来跑
+### Putting It Together
```
-1. Lead: "搭建后端——任务太多,让队友自己认领"
-2. Lead → create_task("创建数据库 schema")
-3. Lead → create_task("写 API 路由")
-4. Lead → create_task("写单元测试")
-5. Lead → spawn_teammate("alice", "backend", "你是后端开发者")
-6. Lead → spawn_teammate("bob", "backend", "你是后端开发者")
+1. Lead: "Build the backend — too many tasks, let teammates self-claim"
+2. Lead → create_task("Create database schema")
+3. Lead → create_task("Write API routes")
+4. Lead → create_task("Write unit tests")
+5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
+6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
-7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
-8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
+7. alice thread starts → WORK: no initial inbox → spins → IDLE
+8. bob thread starts → WORK: no initial inbox → spins → IDLE
-9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema"
-10. alice → claim_task → "创建数据库 schema" → 回到 WORK
-11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由"
-12. bob → claim_task → "写 API 路由" → 回到 WORK
+9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
+10. alice → claim_task → "Create database schema" → back to WORK
+11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
+12. bob → claim_task → "Write API routes" → back to WORK
-13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束
-14. alice IDLE → scan → "写单元测试" → claim → WORK
-15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束
-16. alice IDLE → 60s 无新任务 → SHUTDOWN
+13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
+14. alice IDLE → scan → "Write unit tests" → claim → WORK
+15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
+16. alice IDLE → 60s no new tasks → SHUTDOWN
-17. bob 类似流程 → 做完 → SHUTDOWN
-18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary
+17. bob similar flow → done → SHUTDOWN
+18. Lead consume_lead_inbox → sees alice and bob's summaries
```
-两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
+Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
---
-## 相对 s16 的变更
+## Changes from s16
-| 组件 | 之前 (s16) | 之后 (s17) |
-|------|-----------|-----------|
-| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |
-| 队友状态 | WORK → IDLE(每 1s 轮询 inbox)→ WORK / SHUTDOWN | WORK → IDLE(每 5s 轮询 inbox + 任务板,60s 超时)→ WORK / SHUTDOWN |
-| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |
-| IDLE 阶段关机 | 收到 shutdown_request 后退出 | 直接 dispatch shutdown 并退出 |
-| Lead inbox | consume_lead_inbox 路由协议响应并注入上下文 | 沿用 consume_lead_inbox 机制 |
-| 新函数 | 已有 consume_lead_inbox | idle_poll, scan_unclaimed_tasks(沿用 consume_lead_inbox) |
-| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
-| Lead 工具 | 14 | 14(不变) |
-| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) |
-| 队友退出条件 | WORK 完进入 IDLE,等待 shutdown_request 后退出(无超时) | 60s 无新任务或收到 shutdown_request 后退出 |
+| Component | Before (s16) | After (s17) |
+|-----------|-------------|-------------|
+| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
+| Teammate state | WORK → IDLE (1s inbox poll) → WORK / SHUTDOWN | WORK → IDLE (5s inbox + task board poll, 60s timeout) → WORK / SHUTDOWN |
+| claim_task | No owner check | Rejects tasks that already have an owner |
+| IDLE phase shutdown | Exits after receiving shutdown_request | Dispatches shutdown immediately and exits |
+| Lead inbox | consume_lead_inbox routes protocol responses and injects into context | Reuses consume_lead_inbox mechanism |
+| New functions | consume_lead_inbox already exists | idle_poll, scan_unclaimed_tasks (reuses consume_lead_inbox) |
+| Identity persistence | System prompt only | Auto re-inject after compression |
+| Lead tools | 14 | 14 (unchanged) |
+| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
+| Teammate exit | WORK ends → enters IDLE, waits for shutdown_request (no timeout) | Exits after 60s idle timeout or receiving shutdown_request |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
-试试这个 prompt:
+Try this prompt:
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
-观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?
+What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
---
-## 接下来
+## What's Next
-队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。
+Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
-s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。
+s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
-深入 CC 源码
+Deep Dive into CC Source
-> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
+> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
-### 一、CC 的空闲机制:组合路径,不是单一轮询
+### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
-教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
+Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
-**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
+**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
-**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
+**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
-**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。
+**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
-**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,CC 同时有被动通知和主动认领。
+**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
-### 二、任务认领:文件锁 + 原子操作
+### 2. Task Claiming: File Locks + Atomic Operations
-`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。
+`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
-`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。
+`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
-### 三、教学版 vs CC 对比
+### 3. Teaching Version vs CC Comparison
-| 维度 | 教学版 (s17) | CC |
-|------|-------------|-----|
-| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher |
-| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |
-| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |
-| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |
-| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
-| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |
-| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
-| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
+| Dimension | Teaching (s17) | CC |
+|-----------|----------------|-----|
+| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
+| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
+| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
+| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
+| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
+| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
+| Identity persistence | Messages length detection | Context compaction preserves system prompt |
+| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
-教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
+Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
diff --git a/s17_autonomous_agents/README.zh.md b/s17_autonomous_agents/README.zh.md
new file mode 100644
index 00000000..49255b68
--- /dev/null
+++ b/s17_autonomous_agents/README.zh.md
@@ -0,0 +1,273 @@
+# s17: Autonomous Agents — 自己看板,自己认领
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
+
+> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
+>
+> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
+
+> **核心与选学:** 空闲发现工作与原子认领是本章核心;身份重注入只是教学版的进阶补丁,第一次学习可以跳过。
+
+---
+
+## 问题
+
+s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
+
+---
+
+## 解决方案
+
+
+
+沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。
+
+队友生命周期从两阶段变成三阶段:
+
+| 阶段 | 行为 | 退出条件 |
+|------|------|---------|
+| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
+| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |
+| SHUTDOWN | 发 summary,退出 | — |
+
+---
+
+## 工作原理
+
+### idle_poll: 空闲轮询
+
+队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
+
+```python
+IDLE_POLL_INTERVAL = 5 # seconds
+IDLE_TIMEOUT = 60 # seconds
+
+def idle_poll(name, messages, role) -> str:
+ """Return 'work', 'shutdown', or 'timeout'."""
+ for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
+ time.sleep(IDLE_POLL_INTERVAL)
+
+ # ① 检查收件箱(优先)
+ inbox = BUS.read_inbox(name)
+ if inbox:
+ # shutdown_request 立即处理
+ for msg in inbox:
+ if msg.get("type") == "shutdown_request":
+ # ... 回复 shutdown_response
+ return "shutdown"
+ # 普通消息注入上下文,回到 WORK
+ messages.append(...)
+ return "work"
+
+ # ② 扫描任务看板
+ unclaimed = scan_unclaimed_tasks()
+ if unclaimed:
+ task = unclaimed[0]
+ result = claim_task(task["id"], name)
+ if "Claimed" in result:
+ messages.append(...)
+ return "work"
+ return "timeout"
+```
+
+inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。
+
+### scan_unclaimed_tasks: 扫描任务看板
+
+找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:
+
+```python
+def scan_unclaimed_tasks() -> list[dict]:
+ unclaimed = []
+ for f in sorted(TASKS_DIR.glob("task_*.json")):
+ task = json.loads(f.read_text())
+ if (task.get("status") == "pending"
+ and not task.get("owner")
+ and can_start(task["id"])):
+ unclaimed.append(task)
+ return unclaimed
+```
+
+三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。
+
+### claim_task: owner 检查
+
+自动认领时检查 claim 结果,不把失败当成功:
+
+```python
+def claim_task(task_id: str, owner: str = "agent") -> str:
+ task = load_task(task_id)
+ if task.status != "pending":
+ return f"Task {task_id} is {task.status}, cannot claim"
+ if task.owner:
+ return f"Task {task_id} already owned by {task.owner}"
+ if not can_start(task_id):
+ return f"Blocked by: {deps}"
+ task.owner = owner
+ task.status = "in_progress"
+ save_task(task)
+ return f"Claimed {task.id} ({task.subject})"
+```
+
+教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
+
+### 队友生命周期: WORK → IDLE → SHUTDOWN
+
+s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:
+
+```python
+# Outer loop: WORK → IDLE cycle
+while True:
+ # WORK phase: 内层循环(最多 10 轮 LLM 调用)
+ for _ in range(10):
+ # 检查 inbox、处理协议消息、调 LLM、执行工具
+ ...
+ if response.stop_reason != "tool_use":
+ break # WORK 阶段结束
+
+ # IDLE phase
+ idle_result = idle_poll(name, messages, role)
+ if idle_result == "shutdown":
+ break
+ if idle_result == "timeout":
+ break # 60s 超时 → SHUTDOWN
+
+# SHUTDOWN: 发 summary 给 Lead
+BUS.send(name, "lead", summary, "result")
+```
+
+关键设计:
+- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求
+- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环)
+- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出
+- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复
+
+### 进阶(选学):身份重注入
+
+autoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
+
+```python
+if len(messages) <= 3:
+ messages.insert(0, {"role": "user",
+ "content": f"You are '{name}', role: {role}. "
+ f"Continue your work."})
+```
+
+消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。
+
+### consume_lead_inbox: 统一 inbox 消费
+
+`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。
+
+### 合起来跑
+
+```
+1. Lead: "搭建后端——任务太多,让队友自己认领"
+2. Lead → create_task("创建数据库 schema")
+3. Lead → create_task("写 API 路由")
+4. Lead → create_task("写单元测试")
+5. Lead → spawn_teammate("alice", "backend", "你是后端开发者")
+6. Lead → spawn_teammate("bob", "backend", "你是后端开发者")
+
+7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
+8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
+
+9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema"
+10. alice → claim_task → "创建数据库 schema" → 回到 WORK
+11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由"
+12. bob → claim_task → "写 API 路由" → 回到 WORK
+
+13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束
+14. alice IDLE → scan → "写单元测试" → claim → WORK
+15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束
+16. alice IDLE → 60s 无新任务 → SHUTDOWN
+
+17. bob 类似流程 → 做完 → SHUTDOWN
+18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary
+```
+
+两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
+
+---
+
+## 相对 s16 的变更
+
+| 组件 | 之前 (s16) | 之后 (s17) |
+|------|-----------|-----------|
+| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |
+| 队友状态 | WORK → IDLE(每 1s 轮询 inbox)→ WORK / SHUTDOWN | WORK → IDLE(每 5s 轮询 inbox + 任务板,60s 超时)→ WORK / SHUTDOWN |
+| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |
+| IDLE 阶段关机 | 收到 shutdown_request 后退出 | 直接 dispatch shutdown 并退出 |
+| Lead inbox | consume_lead_inbox 路由协议响应并注入上下文 | 沿用 consume_lead_inbox 机制 |
+| 新函数 | 已有 consume_lead_inbox | idle_poll, scan_unclaimed_tasks(沿用 consume_lead_inbox) |
+| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
+| Lead 工具 | 14 | 14(不变) |
+| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) |
+| 队友退出条件 | WORK 完进入 IDLE,等待 shutdown_request 后退出(无超时) | 60s 无新任务或收到 shutdown_request 后退出 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s17_autonomous_agents/code.py
+```
+
+试试这个 prompt:
+
+`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
+
+观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?
+
+---
+
+## 接下来
+
+队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。
+
+s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。
+
+
+深入 CC 源码
+
+> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
+
+### 一、CC 的空闲机制:组合路径,不是单一轮询
+
+教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
+
+**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
+
+**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
+
+**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。
+
+**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,CC 同时有被动通知和主动认领。
+
+### 二、任务认领:文件锁 + 原子操作
+
+`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。
+
+`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。
+
+### 三、教学版 vs CC 对比
+
+| 维度 | 教学版 (s17) | CC |
+|------|-------------|-----|
+| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher |
+| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |
+| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |
+| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |
+| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
+| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |
+| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
+| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
+
+教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
+
+
+
+
diff --git a/s18_worktree_isolation/README.en.md b/s18_worktree_isolation/README.en.md
deleted file mode 100644
index 19834907..00000000
--- a/s18_worktree_isolation/README.en.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# s18: Worktree Isolation — Separate Directories, No Conflicts
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20
-
-> *"Separate directories, no conflicts"* — Tasks own the goal, worktrees own the directory, bound by ID.
->
-> **Harness Layer**: Isolation — Parallel execution in separate directories.
-
----
-
-## The Problem
-
-In s17, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page".
-
-Alice calls `write_file("config.py", ...)`. Bob also calls `write_file("config.py", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback — you can't tell whose changes are whose.
-
-s15-s17 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work".
-
----
-
-## The Solution
-
-
-
-Git worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/` — no conflicts.
-
-Carries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds:
-
-| Capability | Purpose |
-|------------|---------|
-| create_worktree | Create isolated directory + branch for a task |
-| bind_task_to_worktree | Bind task and directory (no status change) |
-| remove_worktree / keep_worktree | Cleanup or preserve after completion |
-| validate_worktree_name | Reject path traversal and illegal characters |
-
----
-
-## How It Works
-
-### Creation: Task-Worktree Binding
-
-```python
-def create_worktree(name: str, task_id: str = "") -> str:
- validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64}
- path = WORKTREES_DIR / name
- ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
- if not ok:
- return f"Git error: {result}"
- if task_id:
- bind_task_to_worktree(task_id, name)
- log_event("create", name, task_id)
- return f"Worktree '{name}' created at {path}"
-
-def bind_task_to_worktree(task_id: str, worktree_name: str):
- task = load_task(task_id)
- task.worktree = worktree_name # Write worktree field only
- save_task(task) # Status stays pending, waits for teammate claim
-```
-
-Binding rule: one task binds to one worktree. Binding does NOT change task status — the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle.
-
-### Teammate Tool Cwd Switching
-
-Teaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory:
-
-```python
-# Inside teammate thread
-wt_ctx = {"path": None}
-
-def _run_claim_task(task_id):
- result = claim_task(task_id, owner=name)
- if "Claimed" in result:
- task = load_task(task_id)
- if task.worktree:
- wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)
- return result
-
-def _run_bash(command):
- return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree
-```
-
-This is a teaching simplification. Real CC's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution.
-
-### Cleanup: Keep or Remove
-
-After task completion, two choices:
-
-```python
-def remove_worktree(name: str, discard_changes: bool = False) -> str:
- # Safety check: refuse by default if changes exist
- if not discard_changes:
- files, commits = _count_worktree_changes(path)
- if files > 0 or commits > 0:
- return "Has uncommitted changes. Use discard_changes=true to force, or keep_worktree"
- ok, _ = run_git(["worktree", "remove", str(path), "--force"])
- if not ok:
- return "Remove failed"
- run_git(["branch", "-D", f"wt/{name}"])
- log_event("remove", name)
-
-def keep_worktree(name: str) -> str:
- log_event("keep", name)
- return f"Worktree '{name}' kept for review (branch: wt/{name})"
-```
-
-Keep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task — task completion is triggered explicitly by the teammate's `complete_task`.
-
-### Event Log: Auditable
-
-Each lifecycle operation writes to a log for auditing:
-
-```python
-def log_event(event_type: str, worktree_name: str, task_id: str = ""):
- event = {"type": event_type, "worktree": worktree_name,
- "task_id": task_id, "ts": time.time()}
- # append to .worktrees/events.jsonl
-```
-
-Event types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning.
-
-### run_git: Returns Success/Failure
-
-```python
-def run_git(args: list[str]) -> tuple[bool, str]:
- r = subprocess.run(["git"] + args, cwd=WORKDIR, ...)
- return r.returncode == 0, output
-```
-
-`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state.
-
----
-
-## Changes from s17
-
-| Component | Before (s17) | After (s18) |
-|-----------|-------------|-------------|
-| Working directory | All agents share WORKDIR | Each task can bind to a git worktree |
-| Task data | id/subject/status/owner/blockedBy | + worktree field |
-| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task |
-| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
-| Worktree safety | None | Name validation + refuse removal with changes |
-| Event log | None | events.jsonl lifecycle auditing |
-| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
-| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s18_worktree_isolation/code.py
-```
-
-Try this prompt:
-
-`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
-
-What to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding?
-
----
-
-## What's Next
-
-Agent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote — bash, read, write, task...
-
-What if users already have their own tools? Like an internal Jira API, or a custom deployment system?
-
-s19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
-
-
-Deep Dive into CC Source
-
-CC's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation).
-
-### EnterWorktree: Current Session Switch
-
-`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree — not a prompt hint, but a process-level directory change.
-
-`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`.
-
-### AgentTool Isolation: Sub-Agent Isolation
-
-`AgentTool.tsx:590-641` when `isolation: "worktree"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist.
-
-`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root.
-
-### Name Validation
-
-`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule.
-
-### Path and Branch Naming
-
-Real path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity.
-
-Creation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD.
-
-### State Management
-
-CC has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`.
-
-Teaching version uses the task's `worktree` field for binding, a teaching simplification. CC treats worktree and task as two independent systems, connected through the Agent's context understanding.
-
-
-
-
diff --git a/s18_worktree_isolation/README.ja.md b/s18_worktree_isolation/README.ja.md
index 1edc5a7e..86da2305 100644
--- a/s18_worktree_isolation/README.ja.md
+++ b/s18_worktree_isolation/README.ja.md
@@ -1,8 +1,8 @@
# s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20
+s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
> *"それぞれのディレクトリ、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
diff --git a/s18_worktree_isolation/README.md b/s18_worktree_isolation/README.md
index fcf39ac1..e7339cab 100644
--- a/s18_worktree_isolation/README.md
+++ b/s18_worktree_isolation/README.md
@@ -1,49 +1,49 @@
-# s18: Worktree Isolation — 各干各的,互不干扰
+# s18: Worktree Isolation — Separate Directories, No Conflicts
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20
+s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
-> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。
+> *"Separate directories, no conflicts"* — Tasks own the goal, worktrees own the directory, bound by ID.
>
-> **Harness 层**: 隔离 — 并行执行的目录隔离。
+> **Harness Layer**: Isolation — Parallel execution in separate directories.
---
-## 问题
+## The Problem
-s17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块",Bob 的任务是"重构 UI 登录页"。
+In s17, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page".
-Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。
+Alice calls `write_file("config.py", ...)`. Bob also calls `write_file("config.py", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback — you can't tell whose changes are whose.
-s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。
+s15-s17 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work".
---
-## 解决方案
+## The Solution
-
+
-Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。
+Git worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/` — no conflicts.
-沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增:
+Carries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds:
-| 能力 | 作用 |
-|------|------|
-| create_worktree | 为任务创建独立目录 + 独立分支 |
-| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |
-| remove_worktree / keep_worktree | 完成后清理或保留 |
-| validate_worktree_name | 拒绝路径穿越和非法字符 |
+| Capability | Purpose |
+|------------|---------|
+| create_worktree | Create isolated directory + branch for a task |
+| bind_task_to_worktree | Bind task and directory (no status change) |
+| remove_worktree / keep_worktree | Cleanup or preserve after completion |
+| validate_worktree_name | Reject path traversal and illegal characters |
---
-## 工作原理
+## How It Works
-### 创建:任务-Worktree 绑定
+### Creation: Task-Worktree Binding
```python
def create_worktree(name: str, task_id: str = "") -> str:
- validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}
+ validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64}
path = WORKTREES_DIR / name
ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
if not ok:
@@ -55,18 +55,18 @@ def create_worktree(name: str, task_id: str = "") -> str:
def bind_task_to_worktree(task_id: str, worktree_name: str):
task = load_task(task_id)
- task.worktree = worktree_name # 只写 worktree 字段
- save_task(task) # 状态保持 pending,等队友 claim
+ task.worktree = worktree_name # Write worktree field only
+ save_task(task) # Status stays pending, waits for teammate claim
```
-绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。
+Binding rule: one task binds to one worktree. Binding does NOT change task status — the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle.
-### 队友工具的 cwd 切换
+### Teammate Tool Cwd Switching
-教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行:
+Teaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory:
```python
-# 队友线程内部
+# Inside teammate thread
wt_ctx = {"path": None}
def _run_claim_task(task_id):
@@ -78,25 +78,25 @@ def _run_claim_task(task_id):
return result
def _run_bash(command):
- return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行
+ return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree
```
-这是教学简化。真实 CC 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。
+This is a teaching simplification. Real CC's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution.
-### 收尾:Keep 还是 Remove
+### Cleanup: Keep or Remove
-任务完成后,两个选择:
+After task completion, two choices:
```python
def remove_worktree(name: str, discard_changes: bool = False) -> str:
- # 安全检查:有改动时默认拒绝
+ # Safety check: refuse by default if changes exist
if not discard_changes:
files, commits = _count_worktree_changes(path)
if files > 0 or commits > 0:
- return "有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留"
+ return "Has uncommitted changes. Use discard_changes=true to force, or keep_worktree"
ok, _ = run_git(["worktree", "remove", str(path), "--force"])
if not ok:
- return "删除失败"
+ return "Remove failed"
run_git(["branch", "-D", f"wt/{name}"])
log_event("remove", name)
@@ -105,11 +105,11 @@ def keep_worktree(name: str) -> str:
return f"Worktree '{name}' kept for review (branch: wt/{name})"
```
-Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。
+Keep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task — task completion is triggered explicitly by the teammate's `complete_task`.
-### 事件流:可审计
+### Event Log: Auditable
-每次生命周期操作写入日志,方便排查:
+Each lifecycle operation writes to a log for auditing:
```python
def log_event(event_type: str, worktree_name: str, task_id: str = ""):
@@ -118,9 +118,9 @@ def log_event(event_type: str, worktree_name: str, task_id: str = ""):
# append to .worktrees/events.jsonl
```
-事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。
+Event types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning.
-### run_git:返回成功/失败
+### run_git: Returns Success/Failure
```python
def run_git(args: list[str]) -> tuple[bool, str]:
@@ -128,81 +128,81 @@ def run_git(args: list[str]) -> tuple[bool, str]:
return r.returncode == 0, output
```
-`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。
+`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state.
---
-## 相对 s17 的变更
+## Changes from s17
-| 组件 | 之前 (s17) | 之后 (s18) |
-|------|-----------|-----------|
-| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |
-| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |
-| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |
-| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
-| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |
-| 事件日志 | 无 | events.jsonl 生命周期审计 |
-| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
-| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) |
+| Component | Before (s17) | After (s18) |
+|-----------|-------------|-------------|
+| Working directory | All agents share WORKDIR | Each task can bind to a git worktree |
+| Task data | id/subject/status/owner/blockedBy | + worktree field |
+| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task |
+| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
+| Worktree safety | None | Name validation + refuse removal with changes |
+| Event log | None | events.jsonl lifecycle auditing |
+| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
+| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s18_worktree_isolation/code.py
```
-试试这个 prompt:
+Try this prompt:
`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
-观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?
+What to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding?
---
-## 接下来
+## What's Next
-Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task...
+Agent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote — bash, read, write, task...
-如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?
+What if users already have their own tools? Like an internal Jira API, or a custom deployment system?
-s19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。
+s19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
-深入 CC 源码
+Deep Dive into CC Source
-CC 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。
+CC's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation).
-### EnterWorktree:当前会话切换
+### EnterWorktree: Current Session Switch
-`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree——不是 prompt 提醒,而是进程级目录变更。
+`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree — not a prompt hint, but a process-level directory change.
-`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。
+`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`.
-### AgentTool isolation:子 agent 隔离
+### AgentTool Isolation: Sub-Agent Isolation
-`AgentTool.tsx:590-641` 在 `isolation: "worktree"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。
+`AgentTool.tsx:590-641` when `isolation: "worktree"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist.
-`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。
+`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root.
-### name 校验
+### Name Validation
-`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。
+`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule.
-### 路径和分支命名
+### Path and Branch Naming
-真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。
+Real path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity.
-创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。
+Creation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD.
-### 状态管理
+### State Management
-CC 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。
+CC has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`.
-教学版用 task 的 `worktree` 字段做绑定,是教学简化。CC 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。
+Teaching version uses the task's `worktree` field for binding, a teaching simplification. CC treats worktree and task as two independent systems, connected through the Agent's context understanding.
-
+
diff --git a/s18_worktree_isolation/README.zh.md b/s18_worktree_isolation/README.zh.md
new file mode 100644
index 00000000..0cc17529
--- /dev/null
+++ b/s18_worktree_isolation/README.zh.md
@@ -0,0 +1,208 @@
+# s18: Worktree Isolation — 各干各的,互不干扰
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
+
+> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。
+>
+> **Harness 层**: 隔离 — 并行执行的目录隔离。
+
+---
+
+## 问题
+
+s17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块",Bob 的任务是"重构 UI 登录页"。
+
+Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。
+
+s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。
+
+---
+
+## 解决方案
+
+
+
+Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。
+
+沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增:
+
+| 能力 | 作用 |
+|------|------|
+| create_worktree | 为任务创建独立目录 + 独立分支 |
+| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |
+| remove_worktree / keep_worktree | 完成后清理或保留 |
+| validate_worktree_name | 拒绝路径穿越和非法字符 |
+
+---
+
+## 工作原理
+
+### 创建:任务-Worktree 绑定
+
+```python
+def create_worktree(name: str, task_id: str = "") -> str:
+ validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}
+ path = WORKTREES_DIR / name
+ ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
+ if not ok:
+ return f"Git error: {result}"
+ if task_id:
+ bind_task_to_worktree(task_id, name)
+ log_event("create", name, task_id)
+ return f"Worktree '{name}' created at {path}"
+
+def bind_task_to_worktree(task_id: str, worktree_name: str):
+ task = load_task(task_id)
+ task.worktree = worktree_name # 只写 worktree 字段
+ save_task(task) # 状态保持 pending,等队友 claim
+```
+
+绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。
+
+### 队友工具的 cwd 切换
+
+教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行:
+
+```python
+# 队友线程内部
+wt_ctx = {"path": None}
+
+def _run_claim_task(task_id):
+ result = claim_task(task_id, owner=name)
+ if "Claimed" in result:
+ task = load_task(task_id)
+ if task.worktree:
+ wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)
+ return result
+
+def _run_bash(command):
+ return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行
+```
+
+这是教学简化。真实 CC 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。
+
+### 收尾:Keep 还是 Remove
+
+任务完成后,两个选择:
+
+```python
+def remove_worktree(name: str, discard_changes: bool = False) -> str:
+ # 安全检查:有改动时默认拒绝
+ if not discard_changes:
+ files, commits = _count_worktree_changes(path)
+ if files > 0 or commits > 0:
+ return "有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留"
+ ok, _ = run_git(["worktree", "remove", str(path), "--force"])
+ if not ok:
+ return "删除失败"
+ run_git(["branch", "-D", f"wt/{name}"])
+ log_event("remove", name)
+
+def keep_worktree(name: str) -> str:
+ log_event("keep", name)
+ return f"Worktree '{name}' kept for review (branch: wt/{name})"
+```
+
+Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。
+
+### 事件流:可审计
+
+每次生命周期操作写入日志,方便排查:
+
+```python
+def log_event(event_type: str, worktree_name: str, task_id: str = ""):
+ event = {"type": event_type, "worktree": worktree_name,
+ "task_id": task_id, "ts": time.time()}
+ # append to .worktrees/events.jsonl
+```
+
+事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。
+
+### run_git:返回成功/失败
+
+```python
+def run_git(args: list[str]) -> tuple[bool, str]:
+ r = subprocess.run(["git"] + args, cwd=WORKDIR, ...)
+ return r.returncode == 0, output
+```
+
+`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。
+
+---
+
+## 相对 s17 的变更
+
+| 组件 | 之前 (s17) | 之后 (s18) |
+|------|-----------|-----------|
+| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |
+| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |
+| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |
+| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
+| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |
+| 事件日志 | 无 | events.jsonl 生命周期审计 |
+| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
+| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s18_worktree_isolation/code.py
+```
+
+试试这个 prompt:
+
+`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
+
+观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?
+
+---
+
+## 接下来
+
+Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task...
+
+如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?
+
+s19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。
+
+
+深入 CC 源码
+
+CC 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。
+
+### EnterWorktree:当前会话切换
+
+`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree——不是 prompt 提醒,而是进程级目录变更。
+
+`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。
+
+### AgentTool isolation:子 agent 隔离
+
+`AgentTool.tsx:590-641` 在 `isolation: "worktree"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。
+
+`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。
+
+### name 校验
+
+`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。
+
+### 路径和分支命名
+
+真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。
+
+创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。
+
+### 状态管理
+
+CC 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。
+
+教学版用 task 的 `worktree` 字段做绑定,是教学简化。CC 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。
+
+
+
+
diff --git a/s19_mcp_plugin/README.en.md b/s19_mcp_plugin/README.en.md
deleted file mode 100644
index 92e0a315..00000000
--- a/s19_mcp_plugin/README.en.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# s19: MCP Tools — External Tools, Standard Protocol
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/)
-
-> *"External tools, standard protocol"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.
->
-> **Harness layer**: Plugins — External capabilities via a standard protocol.
-
----
-
-## The Problem
-
-From s01 through s18, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.
-
-Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.
-
-You need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.
-
----
-
-## The Solution
-
-
-
-MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:
-
-| Concept | Purpose |
-|------|------|
-| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |
-| MCP Server | The external service — implements `tools/list` + `tools/call` |
-| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |
-| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers |
-
-Carries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds: the `connect_mcp` tool — connect to external services, discover tools, add them to the tool pool.
-
-The tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management.
-
----
-
-## How It Works
-
-### MCPClient: Discovery + Invocation
-
-```python
-class MCPClient:
- def __init__(self, name: str):
- self.name = name
- self.tools: list[dict] = []
- self._handlers: dict[str, callable] = {}
-
- def register(self, tool_defs, handlers):
- """Simulates tools/list discovery."""
- self.tools = tool_defs
- self._handlers = handlers
-
- def call_tool(self, tool_name: str, args: dict) -> str:
- """Simulates tools/call."""
- handler = self._handlers.get(tool_name)
- if not handler:
- return f"MCP error: unknown tool '{tool_name}'"
- return handler(**args)
-```
-
-The tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC.
-
-### connect_mcp: Connect + Discover
-
-```python
-def connect_mcp(name: str) -> str:
- if name in mcp_clients:
- return f"MCP server '{name}' already connected"
- factory = MOCK_SERVERS.get(name)
- if not factory:
- return f"Unknown server '{name}'. Available: ..."
- mcp_client = factory()
- mcp_clients[name] = mcp_client
- return f"Connected to '{name}'. Discovered: ..."
-```
-
-After connecting, the server's tools are immediately available.
-
-### normalize_mcp_name: Name Normalization
-
-```python
-_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')
-
-def normalize_mcp_name(name: str) -> str:
- return _DISALLOWED_CHARS.sub('_', name)
-```
-
-All non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.
-
-### assemble_tool_pool: Assemble Tool Pool
-
-```python
-def assemble_tool_pool() -> tuple[list[dict], dict]:
- tools = list(BUILTIN_TOOLS)
- handlers = dict(BUILTIN_HANDLERS)
- for server_name, mcp_client in mcp_clients.items():
- safe_server = normalize_mcp_name(server_name)
- for tool_def in mcp_client.tools:
- safe_tool = normalize_mcp_name(tool_def["name"])
- prefixed = f"mcp__{safe_server}__{safe_tool}"
- tools.append(...)
- handlers[prefixed] = (
- lambda *, c=mcp_client, t=tool_def["name"], **kw:
- c.call_tool(t, kw))
- return tools, handlers
-```
-
-The prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.
-
-MCP tool descriptions include `(readOnly)` or `(destructive)` annotations — the tutorial uses text annotations, while real CC uses structured tool annotations for the permission system.
-
-### No Cache: Tool Pool Changes, Prompt Changes Too
-
-s10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache:
-
-```python
-def agent_loop(messages, context):
- tools, handlers = assemble_tool_pool() # Rebuild every time
- system = assemble_system_prompt(context) # Regenerate every time
- ...
- if any(b.name == "connect_mcp" ...):
- tools, handlers = assemble_tool_pool() # Rebuild after connection
- system = assemble_system_prompt(context)
-```
-
-Reason: after `connect_mcp`, the tool pool changes — new tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time.
-
-### MCP Tools: Lead Only
-
-In the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task).
-
-This is a teaching simplification. In real CC, MCP tools are available to both the main agent and sub-agents — sub-agents inherit the parent's MCP configuration.
-
----
-
-## Changes from s18
-
-| Component | Before (s18) | After (s19) |
-|------|-----------|-----------|
-| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |
-| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\_\_ prefixed tools |
-| Name safety | None | normalize_mcp_name normalization |
-| New type | — | MCPClient class (simulates tools/list + tools/call) |
-| Namespace | — | mcp\_\_server\_\_tool prevents collisions |
-| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |
-| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |
-| Lead tools | 17 (s18) | 18 (+connect_mcp) |
-| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) |
-| Extension method | Write code to add tools | Standard protocol, implement servers in any language |
-
----
-
-## Try It Out
-
-```sh
-cd learn-claude-code
-python s19_mcp_plugin/code.py
-```
-
-Try these prompts:
-
-1. `Connect to the docs MCP server and search for something`
-2. `Connect to the deploy server and trigger a deployment`
-3. `Connect both servers — what tools are now available?`
-
-What to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?
-
----
-
-## What's Next
-
-The Agent can now connect external tools through a standard protocol. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos.
-
-Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.
-
-s20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.
-
-
-Deep Dive into CC Source
-
-> The following is based on analysis of CC source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`.
-
-### 1. Six Transport Types
-
-The tutorial only shows a stdio mock. CC supports 6 transport types (`types.ts:23-25`):
-
-| Transport | Communication method |
-|-----------|---------|
-| `stdio` | Subprocess stdin/stdout (cross-platform default) |
-| `sse` | HTTP Server-Sent Events |
-| `http` | Streamable HTTP (POST/SSE bidirectional) |
-| `ws` | WebSocket |
-| `sse-ide` | IDE-embedded SSE transport |
-| `sdk` | In-process SDK transport |
-
-On connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20.
-
-### 2. Tool Pool Merging Algorithm
-
-`assembleToolPool()` (`tools.ts:345-364`):
-
-```typescript
-// Dedup with priority: built-in tools win on name collision (sorted first)
-return uniqBy(
- [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],
- 'name',
-)
-```
-
-Built-in and MCP tools are sorted separately, not together. The reason is CC's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position — mixing the sort would break this design.
-
-### 3. Naming Convention: `mcp__server__tool`
-
-`buildMcpToolName()` (`mcpStringUtils.ts:50-52`):
-
-```
-mcp____
-```
-
-All non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule.
-
-### 4. Permission Checks
-
-CC has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools — MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and CC decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement.
-
-### 5. Configuration Sources and Priority
-
-MCP server configuration comes from multiple sources. CC's priority from lowest to highest:
-
-```
-claude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json
-```
-
-`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded.
-
-The tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging.
-
-### 6. Channel Notifications: Servers Push Messages Back
-
-The tutorial only covers agent → MCP Server unidirectional calls. CC also supports reverse notifications (`channelNotification.ts`):
-
-1. Server declares `capabilities.experimental['claude/channel']`
-2. Server sends messages to agent via MCP notification `notifications/claude/channel`
-3. Messages are wrapped in `...` XML tags
-4. Agent is woken up by SleepTool (within 1 second)
-
-Servers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID.
-
-### 7. OAuth Authentication Flow
-
-CC's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow:
-- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728)
-- Local callback server receives authorization code
-- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager)
-- Auto-refresh 5 minutes before expiry
-- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups
-
-### 8. Connection Lifecycle Error Handling
-
-CC has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`):
-- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect
-- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication
-- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours)
-- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order
-
-### The Tutorial's Simplifications
-
-- 6 transport types → 1 (mock stdio): Manageable concept count
-- Channel reverse notifications → omitted: Tutorial agent is always the initiator
-- OAuth flow → omitted: Tutorial assumes servers need no auth
-- Multi-layer config priority → omitted: Tutorial passes server name directly
-- Complex error classification → omitted: Tutorial uses try/except as fallback
-- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure
-
-
-
-
diff --git a/s19_mcp_plugin/README.ja.md b/s19_mcp_plugin/README.ja.md
index 13efac21..5ea052b9 100644
--- a/s19_mcp_plugin/README.ja.md
+++ b/s19_mcp_plugin/README.ja.md
@@ -1,8 +1,8 @@
# s19: MCP Tools — 外部ツール、標準プロトコル
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/)
+s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) → s21 → s22
> *"外部ツール、標準プロトコル"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。
>
diff --git a/s19_mcp_plugin/README.md b/s19_mcp_plugin/README.md
index ade6f587..8150e803 100644
--- a/s19_mcp_plugin/README.md
+++ b/s19_mcp_plugin/README.md
@@ -1,47 +1,47 @@
-# s19: MCP Tools — 外接工具,标准协议
+# s19: MCP Tools — External Tools, Standard Protocol
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/)
+s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) → s21 → s22
-> *"外接工具, 标准协议"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。
+> *"External tools, standard protocol"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.
>
-> **Harness 层**: 插件 — 外部能力通过标准协议接入。
+> **Harness layer**: Plugins — External capabilities via a standard protocol.
---
-## 问题
+## The Problem
-s01 到 s18,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。
+From s01 through s18, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.
-现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。
+Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.
-你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。
+You need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.
---
-## 解决方案
+## The Solution
-
+
-MCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:
+MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:
-| 概念 | 作用 |
+| Concept | Purpose |
|------|------|
-| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |
-| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |
-| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |
-| mcp\_\_server\_\_tool 命名 | 避免不同 server 的工具名冲突 |
+| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |
+| MCP Server | The external service — implements `tools/list` + `tools/call` |
+| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |
+| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers |
-沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具——连接外部服务,发现工具,加入工具池。
+Carries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds: the `connect_mcp` tool — connect to external services, discover tools, add them to the tool pool.
-教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。
+The tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management.
---
-## 工作原理
+## How It Works
-### MCPClient:发现 + 调用
+### MCPClient: Discovery + Invocation
```python
class MCPClient:
@@ -63,9 +63,9 @@ class MCPClient:
return handler(**args)
```
-教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。
+The tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC.
-### connect_mcp:连接 + 发现
+### connect_mcp: Connect + Discover
```python
def connect_mcp(name: str) -> str:
@@ -79,9 +79,9 @@ def connect_mcp(name: str) -> str:
return f"Connected to '{name}'. Discovered: ..."
```
-连接后,server 提供的工具立即可用。
+After connecting, the server's tools are immediately available.
-### normalize_mcp_name:名称规范化
+### normalize_mcp_name: Name Normalization
```python
_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')
@@ -90,9 +90,9 @@ def normalize_mcp_name(name: str) -> str:
return _DISALLOWED_CHARS.sub('_', name)
```
-所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。
+All non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.
-### assemble_tool_pool:组装工具池
+### assemble_tool_pool: Assemble Tool Pool
```python
def assemble_tool_pool() -> tuple[list[dict], dict]:
@@ -110,173 +110,173 @@ def assemble_tool_pool() -> tuple[list[dict], dict]:
return tools, handlers
```
-前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。
+The prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.
-MCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注——教学版用文本标注,真实 CC 用 tool annotations 结构体让权限系统判断。
+MCP tool descriptions include `(readOnly)` or `(destructive)` annotations — the tutorial uses text annotations, while real CC uses structured tool annotations for the permission system.
-### 无缓存:工具池变了,prompt 也变
+### No Cache: Tool Pool Changes, Prompt Changes Too
-s10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存:
+s10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache:
```python
def agent_loop(messages, context):
- tools, handlers = assemble_tool_pool() # 每次重新构建
- system = assemble_system_prompt(context) # 每次重新生成
+ tools, handlers = assemble_tool_pool() # Rebuild every time
+ system = assemble_system_prompt(context) # Regenerate every time
...
if any(b.name == "connect_mcp" ...):
- tools, handlers = assemble_tool_pool() # 连接后重建
+ tools, handlers = assemble_tool_pool() # Rebuild after connection
system = assemble_system_prompt(context)
```
-原因:`connect_mcp` 之后工具池变化了——新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。
+Reason: after `connect_mcp`, the tool pool changes — new tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time.
-### MCP 工具只有 Lead 可用
+### MCP Tools: Lead Only
-教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。
+In the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task).
-这是教学简化。真实 CC 中,MCP 工具对主 agent 和子 agent 都可用——子 agent 继承父级的 MCP 配置。
+This is a teaching simplification. In real CC, MCP tools are available to both the main agent and sub-agents — sub-agents inherit the parent's MCP configuration.
---
-## 相对 s18 的变更
+## Changes from s18
-| 组件 | 之前 (s18) | 之后 (s19) |
+| Component | Before (s18) | After (s19) |
|------|-----------|-----------|
-| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |
-| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\_\_ 前缀工具 |
-| 名称安全 | 无 | normalize_mcp_name 规范化 |
-| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |
-| 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 |
-| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |
-| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 |
-| Lead 工具 | 17 (s18) | 18 (+connect_mcp) |
-| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) |
-| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |
+| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |
+| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\_\_ prefixed tools |
+| Name safety | None | normalize_mcp_name normalization |
+| New type | — | MCPClient class (simulates tools/list + tools/call) |
+| Namespace | — | mcp\_\_server\_\_tool prevents collisions |
+| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |
+| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |
+| Lead tools | 17 (s18) | 18 (+connect_mcp) |
+| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) |
+| Extension method | Write code to add tools | Standard protocol, implement servers in any language |
---
-## 试一下
+## Try It Out
```sh
cd learn-claude-code
python s19_mcp_plugin/code.py
```
-试试这些 prompt:
+Try these prompts:
1. `Connect to the docs MCP server and search for something`
2. `Connect to the deploy server and trigger a deployment`
3. `Connect both servers — what tools are now available?`
-观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?
+What to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?
---
-## 接下来
+## What's Next
-现在 Agent 可以通过标准协议接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。
+The Agent can now connect external tools through a standard protocol. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos.
-工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。
+Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.
-s20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。
+s20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.
-深入 CC 源码
+Deep Dive into CC Source
-> 以下基于 CC 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。
+> The following is based on analysis of CC source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`.
-### 一、6 种 Transport 类型
+### 1. Six Transport Types
-教学版只展示了 stdio mock。CC 支持 6 种传输(`types.ts:23-25`):
+The tutorial only shows a stdio mock. CC supports 6 transport types (`types.ts:23-25`):
-| Transport | 通信方式 |
+| Transport | Communication method |
|-----------|---------|
-| `stdio` | 子进程 stdin/stdout(跨平台默认) |
+| `stdio` | Subprocess stdin/stdout (cross-platform default) |
| `sse` | HTTP Server-Sent Events |
-| `http` | Streamable HTTP(POST/SSE 双向) |
+| `http` | Streamable HTTP (POST/SSE bidirectional) |
| `ws` | WebSocket |
-| `sse-ide` | IDE 内嵌 SSE 传输 |
-| `sdk` | 进程内 SDK 传输 |
+| `sse-ide` | IDE-embedded SSE transport |
+| `sdk` | In-process SDK transport |
-连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。
+On connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20.
-### 二、工具池组装算法
+### 2. Tool Pool Merging Algorithm
-`assembleToolPool()`(`tools.ts:345-364`):
+`assembleToolPool()` (`tools.ts:345-364`):
```typescript
-// 去重时优先保留内置工具(name 相同时内置在前)
+// Dedup with priority: built-in tools win on name collision (sorted first)
return uniqBy(
[...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],
'name',
)
```
-内置工具和 MCP 工具分开排序,不是合起来排。原因是 CC 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点——混排会破坏这个设计。
+Built-in and MCP tools are sorted separately, not together. The reason is CC's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position — mixing the sort would break this design.
-### 三、命名规则:`mcp__server__tool`
+### 3. Naming Convention: `mcp__server__tool`
-`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):
+`buildMcpToolName()` (`mcpStringUtils.ts:50-52`):
```
mcp____
```
-所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。
+All non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule.
-### 四、权限检查
+### 4. Permission Checks
-CC 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具——MCP 工具可以声明自己的权限需求(readOnly、destructive 等),CC 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。
+CC has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools — MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and CC decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement.
-### 五、配置来源与优先级
+### 5. Configuration Sources and Priority
-MCP 服务器配置来自多个来源。CC 的配置优先级从低到高:
+MCP server configuration comes from multiple sources. CC's priority from lowest to highest:
```
-claude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json
+claude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json
```
-`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。
+`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded.
-教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。
+The tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging.
-### 六、Channel 通知:服务器反向推消息
+### 6. Channel Notifications: Servers Push Messages Back
-教学版只讲了 Agent → MCP Server 的单向调用。CC 还支持反向通知(`channelNotification.ts`):
+The tutorial only covers agent → MCP Server unidirectional calls. CC also supports reverse notifications (`channelNotification.ts`):
-1. Server 声明 `capabilities.experimental['claude/channel']`
-2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息
-3. 消息包装在 `...` XML 标签中
-4. Agent 被 SleepTool 唤醒(1 秒内)
+1. Server declares `capabilities.experimental['claude/channel']`
+2. Server sends messages to agent via MCP notification `notifications/claude/channel`
+3. Messages are wrapped in `...` XML tags
+4. Agent is woken up by SleepTool (within 1 second)
-Server 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。
+Servers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID.
-### 七、OAuth 认证流程
+### 7. OAuth Authentication Flow
-CC 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程:
-- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728)
-- 本地回调服务器接收授权码
-- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器)
-- 过期前 5 分钟自动刷新
-- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器
+CC's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow:
+- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728)
+- Local callback server receives authorization code
+- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager)
+- Auto-refresh 5 minutes before expiry
+- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups
-### 八、连接生命周期的错误处理
+### 8. Connection Lifecycle Error Handling
-CC 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`):
-- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连
-- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证
-- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时)
-- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程
+CC has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`):
+- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect
+- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication
+- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours)
+- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order
-### 教学版的简化
+### The Tutorial's Simplifications
-- 6 种 transport → 1 种(mock stdio):概念量可控
-- Channel 反向通知 → 省略:教学版 Agent 是主动方
-- OAuth 流程 → 省略:教学版假设 server 不需要认证
-- 多层配置优先级 → 省略:教学版直接传 server name
-- 复杂的错误分类 → 省略:教学版用 try/except 兜底
-- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构
+- 6 transport types → 1 (mock stdio): Manageable concept count
+- Channel reverse notifications → omitted: Tutorial agent is always the initiator
+- OAuth flow → omitted: Tutorial assumes servers need no auth
+- Multi-layer config priority → omitted: Tutorial passes server name directly
+- Complex error classification → omitted: Tutorial uses try/except as fallback
+- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure
-
+
diff --git a/s19_mcp_plugin/README.zh.md b/s19_mcp_plugin/README.zh.md
new file mode 100644
index 00000000..e9f77d39
--- /dev/null
+++ b/s19_mcp_plugin/README.zh.md
@@ -0,0 +1,282 @@
+# s19: MCP Tools — 外接工具,标准协议
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) → s21 → s22
+
+> *"外接工具, 标准协议"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。
+>
+> **Harness 层**: 插件 — 外部能力通过标准协议接入。
+
+---
+
+## 问题
+
+s01 到 s18,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。
+
+现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。
+
+你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。
+
+---
+
+## 解决方案
+
+
+
+MCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:
+
+| 概念 | 作用 |
+|------|------|
+| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |
+| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |
+| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |
+| mcp\_\_server\_\_tool 命名 | 避免不同 server 的工具名冲突 |
+
+沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具——连接外部服务,发现工具,加入工具池。
+
+教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。
+
+---
+
+## 工作原理
+
+### MCPClient:发现 + 调用
+
+```python
+class MCPClient:
+ def __init__(self, name: str):
+ self.name = name
+ self.tools: list[dict] = []
+ self._handlers: dict[str, callable] = {}
+
+ def register(self, tool_defs, handlers):
+ """Simulates tools/list discovery."""
+ self.tools = tool_defs
+ self._handlers = handlers
+
+ def call_tool(self, tool_name: str, args: dict) -> str:
+ """Simulates tools/call."""
+ handler = self._handlers.get(tool_name)
+ if not handler:
+ return f"MCP error: unknown tool '{tool_name}'"
+ return handler(**args)
+```
+
+教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。
+
+### connect_mcp:连接 + 发现
+
+```python
+def connect_mcp(name: str) -> str:
+ if name in mcp_clients:
+ return f"MCP server '{name}' already connected"
+ factory = MOCK_SERVERS.get(name)
+ if not factory:
+ return f"Unknown server '{name}'. Available: ..."
+ mcp_client = factory()
+ mcp_clients[name] = mcp_client
+ return f"Connected to '{name}'. Discovered: ..."
+```
+
+连接后,server 提供的工具立即可用。
+
+### normalize_mcp_name:名称规范化
+
+```python
+_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')
+
+def normalize_mcp_name(name: str) -> str:
+ return _DISALLOWED_CHARS.sub('_', name)
+```
+
+所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。
+
+### assemble_tool_pool:组装工具池
+
+```python
+def assemble_tool_pool() -> tuple[list[dict], dict]:
+ tools = list(BUILTIN_TOOLS)
+ handlers = dict(BUILTIN_HANDLERS)
+ for server_name, mcp_client in mcp_clients.items():
+ safe_server = normalize_mcp_name(server_name)
+ for tool_def in mcp_client.tools:
+ safe_tool = normalize_mcp_name(tool_def["name"])
+ prefixed = f"mcp__{safe_server}__{safe_tool}"
+ tools.append(...)
+ handlers[prefixed] = (
+ lambda *, c=mcp_client, t=tool_def["name"], **kw:
+ c.call_tool(t, kw))
+ return tools, handlers
+```
+
+前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。
+
+MCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注——教学版用文本标注,真实 CC 用 tool annotations 结构体让权限系统判断。
+
+### 无缓存:工具池变了,prompt 也变
+
+s10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存:
+
+```python
+def agent_loop(messages, context):
+ tools, handlers = assemble_tool_pool() # 每次重新构建
+ system = assemble_system_prompt(context) # 每次重新生成
+ ...
+ if any(b.name == "connect_mcp" ...):
+ tools, handlers = assemble_tool_pool() # 连接后重建
+ system = assemble_system_prompt(context)
+```
+
+原因:`connect_mcp` 之后工具池变化了——新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。
+
+### MCP 工具只有 Lead 可用
+
+教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。
+
+这是教学简化。真实 CC 中,MCP 工具对主 agent 和子 agent 都可用——子 agent 继承父级的 MCP 配置。
+
+---
+
+## 相对 s18 的变更
+
+| 组件 | 之前 (s18) | 之后 (s19) |
+|------|-----------|-----------|
+| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |
+| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\_\_ 前缀工具 |
+| 名称安全 | 无 | normalize_mcp_name 规范化 |
+| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |
+| 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 |
+| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |
+| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 |
+| Lead 工具 | 17 (s18) | 18 (+connect_mcp) |
+| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) |
+| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s19_mcp_plugin/code.py
+```
+
+试试这些 prompt:
+
+1. `Connect to the docs MCP server and search for something`
+2. `Connect to the deploy server and trigger a deployment`
+3. `Connect both servers — what tools are now available?`
+
+观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?
+
+---
+
+## 接下来
+
+现在 Agent 可以通过标准协议接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。
+
+工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。
+
+s20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。
+
+
+深入 CC 源码
+
+> 以下基于 CC 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。
+
+### 一、6 种 Transport 类型
+
+教学版只展示了 stdio mock。CC 支持 6 种传输(`types.ts:23-25`):
+
+| Transport | 通信方式 |
+|-----------|---------|
+| `stdio` | 子进程 stdin/stdout(跨平台默认) |
+| `sse` | HTTP Server-Sent Events |
+| `http` | Streamable HTTP(POST/SSE 双向) |
+| `ws` | WebSocket |
+| `sse-ide` | IDE 内嵌 SSE 传输 |
+| `sdk` | 进程内 SDK 传输 |
+
+连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。
+
+### 二、工具池组装算法
+
+`assembleToolPool()`(`tools.ts:345-364`):
+
+```typescript
+// 去重时优先保留内置工具(name 相同时内置在前)
+return uniqBy(
+ [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],
+ 'name',
+)
+```
+
+内置工具和 MCP 工具分开排序,不是合起来排。原因是 CC 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点——混排会破坏这个设计。
+
+### 三、命名规则:`mcp__server__tool`
+
+`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):
+
+```
+mcp____
+```
+
+所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。
+
+### 四、权限检查
+
+CC 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具——MCP 工具可以声明自己的权限需求(readOnly、destructive 等),CC 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。
+
+### 五、配置来源与优先级
+
+MCP 服务器配置来自多个来源。CC 的配置优先级从低到高:
+
+```
+claude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json
+```
+
+`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。
+
+教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。
+
+### 六、Channel 通知:服务器反向推消息
+
+教学版只讲了 Agent → MCP Server 的单向调用。CC 还支持反向通知(`channelNotification.ts`):
+
+1. Server 声明 `capabilities.experimental['claude/channel']`
+2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息
+3. 消息包装在 `...` XML 标签中
+4. Agent 被 SleepTool 唤醒(1 秒内)
+
+Server 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。
+
+### 七、OAuth 认证流程
+
+CC 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程:
+- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728)
+- 本地回调服务器接收授权码
+- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器)
+- 过期前 5 分钟自动刷新
+- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器
+
+### 八、连接生命周期的错误处理
+
+CC 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`):
+- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连
+- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证
+- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时)
+- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程
+
+### 教学版的简化
+
+- 6 种 transport → 1 种(mock stdio):概念量可控
+- Channel 反向通知 → 省略:教学版 Agent 是主动方
+- OAuth 流程 → 省略:教学版假设 server 不需要认证
+- 多层配置优先级 → 省略:教学版直接传 server name
+- 复杂的错误分类 → 省略:教学版用 try/except 兜底
+- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构
+
+
+
+
diff --git a/s20_comprehensive/README.en.md b/s20_comprehensive/README.en.md
deleted file mode 100644
index 07bdcadb..00000000
--- a/s20_comprehensive/README.en.md
+++ /dev/null
@@ -1,250 +0,0 @@
-# s20: Comprehensive Agent — All Mechanisms, One Loop
-
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
-
-s01 → ... → s18 → s19 → `s20`
-
-> *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.
->
-> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system.
-
----
-
-## Problem
-
-The first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled.
-
-A long-running coding agent needs all of these at once:
-
-- tool dispatch and permission boundaries
-- hook extension points
-- todo planning and task graphs
-- skills, memory, and runtime system prompt assembly
-- compaction and error recovery
-- background tasks and cron scheduling
-- teams, protocols, autonomous claiming
-- worktree isolation
-- MCP external tool integration
-
-The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the endpoint chapter: every component is placed back into one harness.
-
----
-
-## Solution
-
-
-
-S20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness:
-
-```text
-user input
- → UserPromptSubmit hooks
- → cron/background notification injection
- → context compact
- → memory + skills + MCP state assemble the system prompt
- → LLM
- → has tool_use block?
- no → Stop hooks → return
- yes → PreToolUse hooks + permission
- → TOOL_HANDLERS / MCP handlers / background dispatch
- → PostToolUse hooks
- → tool_result / task_notification back to messages
- → next round
-```
-
-The loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. CC source does not directly trust `stop_reason == "tool_use"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete.
-
----
-
-## Where Each Component Sits
-
-| Position | Component | Role |
-|----------|-----------|------|
-| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |
-| Before LLM | cron queue | Inject scheduled prompts into `messages` |
-| Before LLM | background notifications | Inject completed background work as `` |
-| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |
-| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |
-| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |
-| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |
-| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |
-| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |
-| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |
-| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |
-| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |
-
----
-
-## What code.py Contains
-
-### Tools and Dispatch
-
-The built-in tool pool contains 27 tools:
-
-```text
-bash, read_file, write_file, edit_file, glob
-todo_write, task, load_skill, compact
-create_task, list_tasks, get_task, claim_task, complete_task
-schedule_cron, list_crons, cancel_cron
-spawn_teammate, send_message, check_inbox
-request_shutdown, request_plan, review_plan
-create_worktree, remove_worktree, keep_worktree
-connect_mcp
-```
-
-`assemble_tool_pool()` assembles these every round:
-
-```text
-BUILTIN_TOOLS + connected MCP tools
-BUILTIN_HANDLERS + mcp__server__tool handlers
-```
-
-After `connect_mcp("docs")`, the next round exposes tools like `mcp__docs__search`.
-
-### Permissions and Hooks
-
-Permission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:
-
-```python
-blocked = trigger_hooks("PreToolUse", block)
-if blocked:
- results.append(tool_result(block.id, blocked))
- continue
-```
-
-That means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.
-
-### Planning and Tasks
-
-S20 keeps two planning layers:
-
-- `todo_write`: lightweight plan for the current session, kept in memory
-- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`
-
-The first keeps a single agent from drifting. The second supports team coordination.
-
-### Subagents and Teams
-
-S20 has two kinds of delegation:
-
-- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.
-- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously.
-
-One-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.
-
-### Memory, Skills, and Prompt
-
-`assemble_system_prompt(context)` assembles each round from:
-
-- identity and tool guidance
-- workspace
-- skills catalog
-- `.memory/MEMORY.md`
-- connected MCP servers
-
-Skills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.
-
-### Compaction and Recovery
-
-Before the LLM call, S20 runs the compaction pipeline:
-
-```text
-tool_result_budget → snip_compact → micro_compact → compact_history
-```
-
-The model call is wrapped with recovery:
-
-- 429: exponential backoff retry
-- 529: exponential backoff, optionally switch to fallback model after repeated failures
-- `max_tokens`: raise max tokens, then request continuation
-- prompt too long: reactive compact and retry
-
-### Background and Cron
-
-Slow bash work does not block the main loop:
-
-```text
-should_run_background → start_background_task → placeholder tool_result
-background done → task_notification → next round injects messages
-```
-
-The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.
-
-### Worktree and MCP
-
-Worktree isolation owns directories:
-
-- `create_worktree(name, task_id)` creates an isolated branch and directory
-- the task `worktree` field binds a task to that directory
-- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory
-
-MCP owns external capability:
-
-- `connect_mcp(name)` connects a mock server
-- `assemble_tool_pool()` assembles MCP tools into the tool pool
-- tool names use `mcp__server__tool`
-
----
-
-## Changes from s19
-
-| Component | s19 | s20 |
-|-----------|-----|-----|
-| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored |
-| permission | omitted in teaching body | runs inside `PreToolUse` hook |
-| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
-| todo | omitted | `todo_write` + reminder |
-| skill | omitted | catalog in system prompt + `load_skill` |
-| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact |
-| error recovery | simple try/except | retry / max_tokens / prompt too long |
-| background | omitted | slow-operation thread + task notification |
-| cron | omitted | daemon scheduler + durable jobs |
-| multi-agent | kept | kept; teammates use basic tools in isolated directories |
-| worktree | kept | kept |
-| MCP | new | kept as part of the final tool pool |
-
----
-
-## Try It
-
-```sh
-cd learn-claude-code
-python s20_comprehensive/code.py
-```
-
-Try:
-
-1. `Create a todo list for inspecting this repo, then list Python files`
-2. `Connect to the docs MCP server and search for agent loop`
-3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`
-4. `remind me of the meeting in 3 minutes.`
-5. `Run npm install in the background and continue reading README.md`
-
-Watch for:
-
-- whether each tool call passes through hooks/permission
-- whether MCP tools appear on the next round after `connect_mcp`
-- whether slow operations return a background placeholder
-- whether cron automatically reminds you when the time arrives
-- whether teammates submit plans and pause before approval
-- whether teammates can claim tasks after plan approval
-- whether teammates switch to the bound worktree directory
-
----
-
-## The End Is the Beginning
-
-From s01 to s20, the code gets more capable, but the core remains unchanged:
-
-```python
-while True:
- response = LLM(messages, tools)
- if not has_tool_use(response.content):
- return
- results = execute_tools(response.content)
- messages.append(tool_results)
-```
-
-Claude Code's complexity is not "another agent brain." It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities.
-
-This is the endpoint of the course: many mechanisms, one loop.
diff --git a/s20_comprehensive/README.ja.md b/s20_comprehensive/README.ja.md
index 157c6c2e..5291e5c0 100644
--- a/s20_comprehensive/README.ja.md
+++ b/s20_comprehensive/README.ja.md
@@ -1,8 +1,8 @@
# s20: Comprehensive Agent — すべての仕組みを 1 つのループへ
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s18 → s19 → `s20`
+s01 → ... → s18 → s19 → `s20` → [s21](../s21_workflow_runtime/) → s22
> *"仕組みは多い、ループは 1 つ"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。
>
@@ -26,7 +26,7 @@ s01 → ... → s18 → s19 → `s20`
- worktree isolation
- MCP external tool integration
-難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S20 は終点章であり、すべての component を 1 つの harness に戻す。
+難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S20 は統合チェックポイントであり、これまでの component を 1 つの harness に戻してから、s21-s22 が編成と目標完了を外側に追加する。
---
@@ -123,6 +123,8 @@ S20 には 2 層の plan がある:
前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。
+目的は近いが実装は別である。`todo_write` は現在のセッションのチェックリスト全体を置き換え、task record は安定 ID と個別のライフサイクル更新を持つ。次節の独立した `task` ツールは「隔離 subagent を一度派遣する」意味であり、Task System ではない。
+
### Subagent と Team
S20 には 2 種類の delegation がある:
@@ -247,4 +249,6 @@ while True:
Claude Code の複雑さは「別の agent brain」ではない。成熟した harness の複雑さだ。model は判断と action selection を担当する。harness は environment、tools、permissions、memory、teams、external capabilities を整理する。
-これが本コースの終点だ:仕組みは多い、ループは 1 つ。
+これは本コースの統合チェックポイントだ:仕組みは多い、ループは 1 つ。
+
+次へ:[s21 Workflow Runtime](../s21_workflow_runtime/) — 編成の形が固定なら、多数の会話ターンではなく、決定的で再開可能なコードへ移す。
diff --git a/s20_comprehensive/README.md b/s20_comprehensive/README.md
index f9f49d71..09b8546d 100644
--- a/s20_comprehensive/README.md
+++ b/s20_comprehensive/README.md
@@ -1,85 +1,85 @@
-# s20: Comprehensive Agent — 全部机制,归到一个循环
+# s20: Comprehensive Agent — All Mechanisms, One Loop
-[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
-s01 → ... → s18 → s19 → `s20`
+s01 → ... → s18 → s19 → `s20` → [s21](../s21_workflow_runtime/) → s22
-> *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。
+> *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.
>
-> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。
+> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system.
---
-## 问题
+## Problem
-前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。
+The first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled.
-一个能长期工作的 coding agent 需要同时拥有:
+A long-running coding agent needs all of these at once:
-- 工具分发和权限边界
-- hooks 扩展点
-- todo 计划和任务图
-- 技能、记忆、系统 prompt 组装
-- 压缩和错误恢复
-- 后台任务和 cron 调度
-- 团队、协议、自治认领
-- worktree 隔离
-- MCP 外部工具接入
+- tool dispatch and permission boundaries
+- hook extension points
+- todo planning and task graphs
+- skills, memory, and runtime system prompt assembly
+- compaction and error recovery
+- background tasks and cron scheduling
+- teams, protocols, autonomous claiming
+- worktree isolation
+- MCP external tool integration
-难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 就是终点章:把所有组件归位。
+The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the integration checkpoint: every earlier component is placed back into one harness before s21-s22 add orchestration and goal closure around it.
---
-## 解决方案
+## Solution
-
+
-S20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness:
+S20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness:
```text
-用户输入
+user input
→ UserPromptSubmit hooks
- → cron/background 通知注入
+ → cron/background notification injection
→ context compact
- → memory + skills + MCP 状态组装 system prompt
+ → memory + skills + MCP state assemble the system prompt
→ LLM
→ has tool_use block?
- 否 → Stop hooks → 返回
- 是 → PreToolUse hooks + permission
+ no → Stop hooks → return
+ yes → PreToolUse hooks + permission
→ TOOL_HANDLERS / MCP handlers / background dispatch
→ PostToolUse hooks
- → tool_result / task_notification 回 messages
- → 下一轮
+ → tool_result / task_notification back to messages
+ → next round
```
-循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。CC 源码里也不直接信任 `stop_reason == "tool_use"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。
+The loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. CC source does not directly trust `stop_reason == "tool_use"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete.
---
-## 组件在循环中的位置
+## Where Each Component Sits
-| 位置 | 组件 | 作用 |
-|------|------|------|
-| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |
-| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |
-| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |
-| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |
-| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |
-| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |
-| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |
-| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |
-| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |
-| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |
-| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |
-| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |
+| Position | Component | Role |
+|----------|-----------|------|
+| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |
+| Before LLM | cron queue | Inject scheduled prompts into `messages` |
+| Before LLM | background notifications | Inject completed background work as `` |
+| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |
+| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |
+| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |
+| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |
+| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |
+| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |
+| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |
+| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |
+| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |
---
-## code.py 包含什么
+## What code.py Contains
-### 工具与分发
+### Tools and Dispatch
-内置工具池包含 27 个工具:
+The built-in tool pool contains 27 tools:
```text
bash, read_file, write_file, edit_file, glob
@@ -92,18 +92,18 @@ create_worktree, remove_worktree, keep_worktree
connect_mcp
```
-`assemble_tool_pool()` 每轮组装:
+`assemble_tool_pool()` assembles these every round:
```text
BUILTIN_TOOLS + connected MCP tools
BUILTIN_HANDLERS + mcp__server__tool handlers
```
-所以 `connect_mcp("docs")` 后,下一轮工具池里会出现 `mcp__docs__search`。
+After `connect_mcp("docs")`, the next round exposes tools like `mcp__docs__search`.
-### 权限和 hooks
+### Permissions and Hooks
-权限不写死在工具执行行里,而是作为 `PreToolUse` hook:
+Permission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:
```python
blocked = trigger_hooks("PreToolUse", block)
@@ -112,107 +112,109 @@ if blocked:
continue
```
-这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。
+That means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.
-### 计划与任务
+### Planning and Tasks
-S20 同时保留两层计划:
+S20 keeps two planning layers:
-- `todo_write`:当前会话内的轻量计划,保存在内存中
-- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`
+- `todo_write`: lightweight plan for the current session, kept in memory
+- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`
-前者帮助单个 Agent 不漂移;后者支撑团队协作。
+The first keeps a single agent from drifting. The second supports team coordination.
-### 子 agent 与团队
+They share an intent, not an implementation: `todo_write` replaces one session checklist, while task records have stable IDs and individual lifecycle updates. The separate `task` tool below means "dispatch one isolated subagent"; it is not the Task System.
-S20 有两种 delegation:
+### Subagents and Teams
-- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。
-- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。
+S20 has two kinds of delegation:
-一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。
+- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.
+- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously.
-### 记忆、技能和 prompt
+One-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.
-`assemble_system_prompt(context)` 每轮组装:
+### Memory, Skills, and Prompt
-- 身份和工具说明
+`assemble_system_prompt(context)` assembles each round from:
+
+- identity and tool guidance
- workspace
- skills catalog
- `.memory/MEMORY.md`
-- 已连接 MCP server
+- connected MCP servers
-技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。
+Skills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.
-### 压缩和恢复
+### Compaction and Recovery
-LLM 前先跑压缩管线:
+Before the LLM call, S20 runs the compaction pipeline:
```text
tool_result_budget → snip_compact → micro_compact → compact_history
```
-调用模型时再包一层恢复:
+The model call is wrapped with recovery:
-- 429:指数退避重试
-- 529:指数退避,连续失败可切 fallback model
-- `max_tokens`:先提高 max_tokens,再要求 continuation
-- prompt too long:reactive compact 后重试
+- 429: exponential backoff retry
+- 529: exponential backoff, optionally switch to fallback model after repeated failures
+- `max_tokens`: raise max tokens, then request continuation
+- prompt too long: reactive compact and retry
-### 后台和 cron
+### Background and Cron
-慢 bash 操作不会阻塞主循环:
+Slow bash work does not block the main loop:
```text
should_run_background → start_background_task → placeholder tool_result
-后台完成 → task_notification → 下一轮注入 messages
+background done → task_notification → next round injects messages
```
-cron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。
+The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.
-### worktree 与 MCP
+### Worktree and MCP
-worktree 负责隔离目录:
+Worktree isolation owns directories:
-- `create_worktree(name, task_id)` 创建独立分支和目录
-- task 的 `worktree` 字段绑定目录
-- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行
+- `create_worktree(name, task_id)` creates an isolated branch and directory
+- the task `worktree` field binds a task to that directory
+- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory
-MCP 负责外部能力:
+MCP owns external capability:
-- `connect_mcp(name)` 连接 mock server
-- `assemble_tool_pool()` 把 MCP 工具组装进工具池
-- 工具名统一为 `mcp__server__tool`
+- `connect_mcp(name)` connects a mock server
+- `assemble_tool_pool()` assembles MCP tools into the tool pool
+- tool names use `mcp__server__tool`
---
-## 相对 s19 的变化
+## Changes from s19
-| 组件 | s19 | s20 |
-|------|-----|-----|
-| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 |
-| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 |
-| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
-| todo | 省略 | `todo_write` + reminder |
-| skill | 省略 | catalog in system prompt + `load_skill` |
-| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact |
-| error recovery | 简化 try/except | retry / max_tokens / prompt too long |
-| background | 省略 | 慢操作后台线程 + task notification |
-| cron | 省略 | daemon scheduler + durable jobs |
-| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |
-| worktree | 保留 | 保留 |
-| MCP | 新增 | 保留,作为最终工具池的一部分 |
+| Component | s19 | s20 |
+|-----------|-----|-----|
+| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored |
+| permission | omitted in teaching body | runs inside `PreToolUse` hook |
+| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
+| todo | omitted | `todo_write` + reminder |
+| skill | omitted | catalog in system prompt + `load_skill` |
+| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact |
+| error recovery | simple try/except | retry / max_tokens / prompt too long |
+| background | omitted | slow-operation thread + task notification |
+| cron | omitted | daemon scheduler + durable jobs |
+| multi-agent | kept | kept; teammates use basic tools in isolated directories |
+| worktree | kept | kept |
+| MCP | new | kept as part of the final tool pool |
---
-## 试一下
+## Try It
```sh
cd learn-claude-code
python s20_comprehensive/code.py
```
-可以试:
+Try:
1. `Create a todo list for inspecting this repo, then list Python files`
2. `Connect to the docs MCP server and search for agent loop`
@@ -220,21 +222,21 @@ python s20_comprehensive/code.py
4. `remind me of the meeting in 3 minutes.`
5. `Run npm install in the background and continue reading README.md`
-观察重点:
+Watch for:
-- 工具调用前是否经过 hooks/permission
-- `connect_mcp` 后下一轮是否出现 MCP 工具
-- 慢操作是否返回 background placeholder
-- 到点是不是自动提醒开会
-- 队友是否提交 plan,并在 approval 前暂停
-- plan 批准后,队友是否能认领任务
-- worktree 绑定后,队友是否切到对应目录
+- whether each tool call passes through hooks/permission
+- whether MCP tools appear on the next round after `connect_mcp`
+- whether slow operations return a background placeholder
+- whether cron automatically reminds you when the time arrives
+- whether teammates submit plans and pause before approval
+- whether teammates can claim tasks after plan approval
+- whether teammates switch to the bound worktree directory
---
-## 结束亦是开始
+## The End Is the Beginning
-从 s01 到 s20,代码表面越来越复杂,但核心始终没变:
+From s01 to s20, the code gets more capable, but the core remains unchanged:
```python
while True:
@@ -245,6 +247,8 @@ while True:
messages.append(tool_results)
```
-Claude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。
+Claude Code's complexity is not "another agent brain." It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities.
-这就是全书的终点:机制很多,循环一个。
+This is the course's integration checkpoint: many mechanisms, one loop.
+
+Next: [s21 Workflow Runtime](../s21_workflow_runtime/) — when the orchestration shape is fixed, move it out of chat turns and into deterministic, resumable code.
diff --git a/s20_comprehensive/README.zh.md b/s20_comprehensive/README.zh.md
new file mode 100644
index 00000000..6084c40a
--- /dev/null
+++ b/s20_comprehensive/README.zh.md
@@ -0,0 +1,254 @@
+# s20: Comprehensive Agent — 全部机制,归到一个循环
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s18 → s19 → `s20` → [s21](../s21_workflow_runtime/) → s22
+
+> *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。
+>
+> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。
+
+---
+
+## 问题
+
+前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。
+
+一个能长期工作的 coding agent 需要同时拥有:
+
+- 工具分发和权限边界
+- hooks 扩展点
+- todo 计划和任务图
+- 技能、记忆、系统 prompt 组装
+- 压缩和错误恢复
+- 后台任务和 cron 调度
+- 团队、协议、自治认领
+- worktree 隔离
+- MCP 外部工具接入
+
+难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 是集成检查点:先把此前组件归位,再由 s21-s22 在外层加入编排与目标闭环。
+
+---
+
+## 解决方案
+
+
+
+S20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness:
+
+```text
+用户输入
+ → UserPromptSubmit hooks
+ → cron/background 通知注入
+ → context compact
+ → memory + skills + MCP 状态组装 system prompt
+ → LLM
+ → has tool_use block?
+ 否 → Stop hooks → 返回
+ 是 → PreToolUse hooks + permission
+ → TOOL_HANDLERS / MCP handlers / background dispatch
+ → PostToolUse hooks
+ → tool_result / task_notification 回 messages
+ → 下一轮
+```
+
+循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。CC 源码里也不直接信任 `stop_reason == "tool_use"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。
+
+---
+
+## 组件在循环中的位置
+
+| 位置 | 组件 | 作用 |
+|------|------|------|
+| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |
+| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |
+| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |
+| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |
+| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |
+| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |
+| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |
+| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |
+| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |
+| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |
+| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |
+| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |
+
+---
+
+## code.py 包含什么
+
+### 工具与分发
+
+内置工具池包含 27 个工具:
+
+```text
+bash, read_file, write_file, edit_file, glob
+todo_write, task, load_skill, compact
+create_task, list_tasks, get_task, claim_task, complete_task
+schedule_cron, list_crons, cancel_cron
+spawn_teammate, send_message, check_inbox
+request_shutdown, request_plan, review_plan
+create_worktree, remove_worktree, keep_worktree
+connect_mcp
+```
+
+`assemble_tool_pool()` 每轮组装:
+
+```text
+BUILTIN_TOOLS + connected MCP tools
+BUILTIN_HANDLERS + mcp__server__tool handlers
+```
+
+所以 `connect_mcp("docs")` 后,下一轮工具池里会出现 `mcp__docs__search`。
+
+### 权限和 hooks
+
+权限不写死在工具执行行里,而是作为 `PreToolUse` hook:
+
+```python
+blocked = trigger_hooks("PreToolUse", block)
+if blocked:
+ results.append(tool_result(block.id, blocked))
+ continue
+```
+
+这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。
+
+### 计划与任务
+
+S20 同时保留两层计划:
+
+- `todo_write`:当前会话内的轻量计划,保存在内存中
+- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`
+
+前者帮助单个 Agent 不漂移;后者支撑团队协作。
+
+两者目标相近,但实现不同:`todo_write` 整表替换当前会话清单,task record 则有稳定 ID 和单条生命周期更新。下面单独出现的 `task` 工具表示“一次性派发隔离 subagent”,不是 Task System。
+
+### 子 agent 与团队
+
+S20 有两种 delegation:
+
+- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。
+- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。
+
+一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。
+
+### 记忆、技能和 prompt
+
+`assemble_system_prompt(context)` 每轮组装:
+
+- 身份和工具说明
+- workspace
+- skills catalog
+- `.memory/MEMORY.md`
+- 已连接 MCP server
+
+技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。
+
+### 压缩和恢复
+
+LLM 前先跑压缩管线:
+
+```text
+tool_result_budget → snip_compact → micro_compact → compact_history
+```
+
+调用模型时再包一层恢复:
+
+- 429:指数退避重试
+- 529:指数退避,连续失败可切 fallback model
+- `max_tokens`:先提高 max_tokens,再要求 continuation
+- prompt too long:reactive compact 后重试
+
+### 后台和 cron
+
+慢 bash 操作不会阻塞主循环:
+
+```text
+should_run_background → start_background_task → placeholder tool_result
+后台完成 → task_notification → 下一轮注入 messages
+```
+
+cron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。
+
+### worktree 与 MCP
+
+worktree 负责隔离目录:
+
+- `create_worktree(name, task_id)` 创建独立分支和目录
+- task 的 `worktree` 字段绑定目录
+- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行
+
+MCP 负责外部能力:
+
+- `connect_mcp(name)` 连接 mock server
+- `assemble_tool_pool()` 把 MCP 工具组装进工具池
+- 工具名统一为 `mcp__server__tool`
+
+---
+
+## 相对 s19 的变化
+
+| 组件 | s19 | s20 |
+|------|-----|-----|
+| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 |
+| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 |
+| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
+| todo | 省略 | `todo_write` + reminder |
+| skill | 省略 | catalog in system prompt + `load_skill` |
+| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact |
+| error recovery | 简化 try/except | retry / max_tokens / prompt too long |
+| background | 省略 | 慢操作后台线程 + task notification |
+| cron | 省略 | daemon scheduler + durable jobs |
+| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |
+| worktree | 保留 | 保留 |
+| MCP | 新增 | 保留,作为最终工具池的一部分 |
+
+---
+
+## 试一下
+
+```sh
+cd learn-claude-code
+python s20_comprehensive/code.py
+```
+
+可以试:
+
+1. `Create a todo list for inspecting this repo, then list Python files`
+2. `Connect to the docs MCP server and search for agent loop`
+3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`
+4. `remind me of the meeting in 3 minutes.`
+5. `Run npm install in the background and continue reading README.md`
+
+观察重点:
+
+- 工具调用前是否经过 hooks/permission
+- `connect_mcp` 后下一轮是否出现 MCP 工具
+- 慢操作是否返回 background placeholder
+- 到点是不是自动提醒开会
+- 队友是否提交 plan,并在 approval 前暂停
+- plan 批准后,队友是否能认领任务
+- worktree 绑定后,队友是否切到对应目录
+
+---
+
+## 结束亦是开始
+
+从 s01 到 s20,代码表面越来越复杂,但核心始终没变:
+
+```python
+while True:
+ response = LLM(messages, tools)
+ if not has_tool_use(response.content):
+ return
+ results = execute_tools(response.content)
+ messages.append(tool_results)
+```
+
+Claude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。
+
+这是课程的集成检查点:机制很多,循环一个。
+
+下一章:[s21 Workflow Runtime](../s21_workflow_runtime/) — 当编排形状固定时,把它从多轮对话移入确定性、可恢复的代码。
diff --git a/s21_workflow_runtime/README.ja.md b/s21_workflow_runtime/README.ja.md
new file mode 100644
index 00000000..83412b38
--- /dev/null
+++ b/s21_workflow_runtime/README.ja.md
@@ -0,0 +1,242 @@
+# s21: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
+
+> *「1 回の tool_use で、バックグラウンドに一式の orchestration を走らせる」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。
+>
+> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。
+
+> **情報源の境界:** この章の製品詳細は Claude Code 2.1.177 の clean-room 行動再構成に基づく。後続リリースで名称や制限は変わり得る。`code.py` はオフライン教材モデルであり、製品ソースの複製ではない。
+>
+> 教材 CLI は `async_launched` を出した後、再現可能な出力のため同じプロセスで完了を待つ。示すのは lifecycle と journal であり、main loop の並行実行そのものではない。
+
+---
+
+s01 から s20 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。
+
+しかし、複数の Agent を決定的に指揮したい仕事もあります。大きな変更の review を考えてください。10 の観点から並行して問題を探す → 各 finding へ別 Agent を送り adversarial verification を行う → 結果を集約して重複を除く → severity 順に並べる。この流れの形は固定されており、本当に必要なのは 3 つです。
+
+- **並行性**: 1 件ずつ順番に待たないこと。
+- **決定性**: 同じ入力から同じ結果構造が得られること。
+- **復元可能性**: 途中で止まっても、完了済みの部分を最初からやり直さないこと。
+
+この流れをモデルに main loop で 1 ラウンドずつ動かさせると、遅く、結果は不確定で、中断すれば最初からです。ここで必要なのは「もう 1 turn 話す」ことではなく、orchestration をそのままコードにすることです。
+
+## 計画は chat のラウンドを重ねず、コードに書く
+
+Claude Code の tool pool には `Workflow` ツールがあります。あなたが渡すか、モデルが high-intensity mode で起動した script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
+
+main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resumeFromRunId` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
+
+
+
+```python
+SAMPLE_META = {"name": "review-changes", "description": "コード変更を review", "phases": ["Review", "Verify"]}
+
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+ results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立して audit → verify を通る
+ confirmed = [f for r in results if r for f in r["confirmed"]]
+ ctx.log(f"{len(confirmed)} 件の実在する問題を確認")
+ return {"confirmed": confirmed}
+```
+
+## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ
+
+`Workflow`(別名 `RunWorkflow`)は main Agent の tool pool にあります。明示的に「この workflow を実行」と頼む、保存済みの `/command` を使う、またはモデルが自動で high-intensity path へ入ると、モデルが `Workflow(...)` の tool call を出します。
+
+ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。
+
+```python
+class WorkflowTool:
+ async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
+ validate_meta(meta)
+ check_permission(meta)
+ run_id = resume_from_run_id or create_run_id(meta)
+ task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
+ task.event("async_launched", runId=run_id, taskId=task.task_id) # すぐ return
+ ... # 残りはバックグラウンドで進む
+```
+
+> 実際の Claude Code は `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` をすぐ返し、background task の完了後に通知します。
+
+## Script と meta: 1 行目を正しく書く
+
+script の 1 行目は必ず `export const meta = { name, description, phases }` とし、変数、関数呼び出し、文字列連結を含まない純粋な literal でなければなりません。runtime はコードを一切実行する前に parse します。`name` と `description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
+
+不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。
+
+教材 runtime は `meta.name` をローカル artifact のファイル名に使うため、英数字で始まり、英数字、`.`、`_`、`-` のみからなる 1-64 文字の安全な slug も要求する。
+
+```python
+def validate_meta(meta):
+ if not isinstance(meta, dict):
+ raise WorkflowInputError("meta は object literal でなければなりません")
+ if not meta.get("name") or not meta.get("description"):
+ raise WorkflowInputError("meta には name と description が必要です")
+ if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
+ raise WorkflowInputError("meta.name は安全な 1-64 文字の slug が必要です")
+ if "phases" in meta and (
+ not isinstance(meta["phases"], list)
+ or not all(isinstance(p, str) and p for p in meta["phases"])
+ ):
+ raise WorkflowInputError("meta.phases は空でない文字列だけを含む必要があります")
+ return meta
+```
+
+> 実際の Claude Code の `parseWorkflowScript` は、meta を 1 行目の純粋な literal に限定します。教材版は dict を直接受け取り、この部分を簡略化しています。
+
+## Orchestration primitive: この少数だけで、すべての flow を書ける
+
+script は独立した context で動き、global variable として使えるのは少数の orchestration primitive だけです。script 自身はファイルを直接読み書きせず、shell も実行しません。実際のコード操作は、派遣された subagent が自分の tool permission で行います。primitive はすべて `ExecutionState` の method です。
+
+| Primitive | 役割 |
+|------|------|
+| `agent(prompt, {schema, label, phase})` | 1 つの subagent を派遣 |
+| `parallel(thunks)` | **barrier**: すべての task を並行実行し、全結果が戻るまで待つ |
+| `pipeline(items, *stages)` | 各 item を **barrier なし**で stage ごとに実行し、終わった item から先へ進める |
+| `phase(title)` | 現在の progress phase を記録し、progress bar を更新 |
+| `log(message)` | progress log を 1 行出力 |
+| `workflow(name, args)` | nested sub-workflow(1 階層だけ) |
+
+既定では `pipeline` を使うべきです。各 item がすべての stage を独立して通り、item A が stage 3 にいる間、item B はまだ stage 1 かもしれません。次の stage へ進むために前 stage の全結果が本当に必要なときだけ、`parallel` barrier を使います。barrier は最も遅い task を待つため、不要なら置かないでください。
+
+```python
+async def pipeline(self, items, *stages):
+ async def run_item(item, idx):
+ value = item
+ for stage in stages: # 各 item がすべての stage を独立して完走
+ value = await stage(value, item, idx)
+ return value
+ return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
+```
+
+> 実際の Claude Code は同名 primitive を script VM の context へ注入します。さらに `args`、total/spent/remaining を持つ `budget`、最大 1000 Agent の上限、concurrency semaphore も提供します。
+
+## 構造化出力: Subagent に散文を返させない
+
+`agent({schema})` は、schema に一致する JSON object を subagent に要求します。内部では structured output call を 1 回使い、runtime が結果を schema で検証し、不一致なら 1 回 retry します。下流コードが受け取るのは規則的な object であり、再 parse が必要な長文ではありません。
+
+s05 では tool argument を全面的に信頼できないと説明しました。ここでは同じ教訓を逆向きに使います。subagent の出力も全面的には信頼できません。orchestration boundary で検証し、1 回 retry の機会を与え、不確実性を後続 flow の外へ止めます。
+
+```python
+result = self.runner.run(prompt, schema, label)
+if schema is not None:
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok: # 1 回だけ注意して retry、それでも不正なら error
+ result = self.runner.run(prompt + "\n\n有効な JSON を返してください。", schema, label)
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok:
+ raise WorkflowInputError(f"agent({{schema}}) の出力が不正です: {err}")
+```
+
+> 実際の Claude Code は `SimpleJsonSchema`、`StructuredOutput` ツール、schema-aware retry を組み合わせ、出力形式を保証します。
+
+## Background task と progress event
+
+`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。
+
+main session は通常 event として処理し、最後の完了通知だけが main loop へ再び入ります。
+
+```python
+class LocalWorkflowTask:
+ def progress_event(self, ptype, **data): # phase/subagent/log
+ self.progress.append({"type": ptype, **data})
+ print(f" progress {ptype} ...")
+```
+
+> 実際の Claude Code は進捗を task state へまとめ、`task_progress.workflow_progress` として UI と SDK へ送ります。
+
+## 保存: Snapshot + journal で中断から再開する
+
+各 run は `~/.claude/projects///` に 5 種類を書きます。`.json` snapshot、`.output.json` output、`.journal.jsonl` journal、`scripts/.js` の script copy、`subagents/workflows//` の subagent transcript です。保存した再利用可能な workflow は project scope の `.claude/workflows/` または user scope の `~/.claude/workflows/` に置きます。
+
+journal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。
+
+```python
+class WorkflowJournal:
+ def record(self, key, value):
+ self._f.write(json.dumps({"key": key, "value": value}) + "\n")
+ self._f.flush()
+ self.cache[key] = value
+```
+
+## Resume: runId から続行し、変更のないものを再利用する
+
+`Workflow({scriptPath, resumeFromRunId, args})` を呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更のない call はすべて cache hit し、変更された call とそれに依存する後続 step だけが本当に動きます。
+
+key は concurrency の完了順に依存してはいけません。`parallel` と `pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。
+
+```python
+def key(self, kind, label, prompt, schema):
+ basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
+ return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
+
+# agent() の内部:
+cached = self.journal.cached(key)
+if cached is not MISS:
+ self.task.progress_event("workflow_agent", label=label, status="cached")
+ return cached
+```
+
+> 実際の Claude Code も「決定的 semantic key + journal cache」という考えです。同じ session で resume すると、完了済み `agent()` は cached result を直接返し、その後だけを実行します。
+
+## 決定性: Resume に意味を持たせる再現性
+
+resume が動くには、まず script が再現可能でなければなりません。runtime は `Date.now()`、引数なしの `new Date()`、`Math.random()` などの非決定的なものを script context から取り除き、Node native API も渡しません。同じ script + 同じ argument → 同じ key → 100% cache hit になります。教材版は stable hash で同じ性質を得ます。実際の版は、非決定的な source を除いた sandbox VM で JavaScript 全体を実行します。
+
+## 実際に動かす
+
+sample workflow `review-changes` は `pipeline` を使い、各 review dimension を独立して audit → verify へ通します。audit では schema 付き `agent()` が問題を探し、verify では `parallel()` が各 finding に別の adversarial verification subagent を送ります。実在すると確認された問題だけを残し、severity 順に並べます。
+
+```python
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+
+ async def audit(_v, dimension, _i):
+ out = await ctx.agent(f"変更されたコードに {dimension} 関連の問題がないか確認してください",
+ schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
+ return {"dimension": dimension, "findings": out["findings"]}
+
+ async def verify(audited, dimension, _i):
+ ctx.phase("Verify")
+ verdicts = await ctx.parallel([ # 各 finding を独立して verify
+ (lambda f=f: ctx.agent(f"この問題が実在するか adversarial に検証してください: {f['title']}",
+ schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
+ for f in audited["findings"]])
+ return {"dimension": dimension,
+ "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
+
+ results = await ctx.pipeline(DIMENSIONS, audit, verify)
+ ...
+```
+
+## s20 からの変更点
+
+| | s20 Comprehensive Agent | s21 Workflow Runtime |
+|--|-----------|---------------------|
+| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |
+| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |
+| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |
+| 新しい仕組み | — | script DSL、background task、progress event、journal/resume、structured output、deterministic VM |
+
+s21 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s21 は orchestration を replay 可能な script にします。
+
+## 試してみる
+
+```bash
+python s21_workflow_runtime/code.py # review-changes を起動し、event stream を確認
+python s21_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる
+```
+
+1 回の起動から `async_launched`、background の phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。
+
+## 次へ
+
+orchestration は Agent 能力の上にもう 1 層を加えます。main loop は個々の操作を管理し、script はチーム全体の flow を管理します。仕事が決定的で復元可能な script になると、モデルは「ラウンドごとの driver」から「script に schedule される実行 unit」へ変わります。同じ `agent()` を main loop でモデルがその場で呼ぶことも、workflow 内で script がまとめて編成することもできます。
+
+次へ: [s22 Goal Loop](../s22_goal_loop/) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
+
+
diff --git a/s21_workflow_runtime/README.md b/s21_workflow_runtime/README.md
new file mode 100644
index 00000000..c1bc887a
--- /dev/null
+++ b/s21_workflow_runtime/README.md
@@ -0,0 +1,242 @@
+# s21: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
+
+> *"One tool_use starts an entire orchestration in the background"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.
+>
+> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.
+
+> **Source boundary:** Product details in this chapter are a clean-room behavioral reconstruction of Claude Code 2.1.177. Names and limits may change in later releases; `code.py` is an offline teaching model, not copied product source.
+>
+> The teaching CLI emits `async_launched` and then awaits completion in one process for deterministic output. It demonstrates the lifecycle and journal, not a concurrently running main loop.
+
+---
+
+From s01 through s20, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.
+
+Some jobs, however, require deterministic command of a group of agents. Consider reviewing a large change: inspect ten dimensions in parallel → send each finding to a separate agent for adversarial verification → combine and deduplicate the results → sort by severity. The shape is fixed, and you really need three properties:
+
+- **Parallelism**, rather than waiting for one item at a time;
+- **Determinism**, so the same input produces the same result structure;
+- **Recoverability**, so an interruption does not rerun work that is already complete.
+
+Making the model drive this process one round at a time in the main loop is slow and nondeterministic, and an interruption starts everything over. At that point, you do not need "one more conversation turn." You need to encode the orchestration directly as code.
+
+## Put the Plan in Code, Not in a Sequence of Chat Turns
+
+Claude Code includes a `Workflow` tool in its tool pool. You, or the model when it enters a high-intensity mode, provide a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
+
+The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resumeFromRunId`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
+
+
+
+```python
+SAMPLE_META = {"name": "review-changes", "description": "Review code changes", "phases": ["Review", "Verify"]}
+
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+ results = await ctx.pipeline(DIMENSIONS, audit, verify) # Each dimension independently runs audit → verify
+ confirmed = [f for r in results if r for f in r["confirmed"]]
+ ctx.log(f"Confirmed {len(confirmed)} real issues")
+ return {"confirmed": confirmed}
+```
+
+## The Workflow Tool: Start in the Background; the Main Loop Sees One Call
+
+`Workflow`, also known as `RunWorkflow`, lives in the main agent's tool pool. You may explicitly ask to "run this workflow," invoke a saved `/command`, or let the model enter a high-intensity path automatically. In each case, the model emits a `Workflow(...)` tool call.
+
+The tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns "started asynchronously." The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.
+
+```python
+class WorkflowTool:
+ async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
+ validate_meta(meta)
+ check_permission(meta)
+ run_id = resume_from_run_id or create_run_id(meta)
+ task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
+ task.event("async_launched", runId=run_id, taskId=task.task_id) # Return immediately
+ ... # The rest proceeds in the background
+```
+
+> The real Claude Code immediately returns `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`, then sends a notification when the background task finishes.
+
+## Script and Meta: The First Line Must Be Correct
+
+The script's first line must be `export const meta = { name, description, phases }`, and it must contain only literals: no variables, function calls, or string concatenation. The runtime parses it before executing any code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.
+
+Invalid input raises `WorkflowInputError` immediately and is rejected during registration. This is the same idea as validating cron expressions in s14: do not wait until execution to discover a bad script.
+
+Because the teaching runtime uses `meta.name` in local artifact filenames, it also requires a 1-64 character safe slug containing letters, numbers, `.`, `_`, or `-`.
+
+```python
+def validate_meta(meta):
+ if not isinstance(meta, dict):
+ raise WorkflowInputError("meta must be an object literal")
+ if not meta.get("name") or not meta.get("description"):
+ raise WorkflowInputError("meta requires name and description")
+ if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
+ raise WorkflowInputError("meta.name must be a safe 1-64 character slug")
+ if "phases" in meta and (
+ not isinstance(meta["phases"], list)
+ or not all(isinstance(p, str) and p for p in meta["phases"])
+ ):
+ raise WorkflowInputError("meta.phases must contain non-empty strings")
+ return meta
+```
+
+> The real Claude Code's `parseWorkflowScript` requires meta to be the first line and a pure literal. The teaching version accepts a dict directly to simplify this part.
+
+## Orchestration Primitives: A Small Set Is Enough for Every Flow
+
+A script runs in an isolated context with only a small set of orchestration primitives as globals. The script does not read files or run shell commands directly. All real code operations are performed by dispatched subagents under their own tool permissions. These primitives are methods on `ExecutionState`:
+
+| Primitive | Purpose |
+|------|------|
+| `agent(prompt, {schema, label, phase})` | Dispatch one subagent |
+| `parallel(thunks)` | **Barrier**: run every task concurrently and wait until all results return |
+| `pipeline(items, *stages)` | Run each item through stages **without a barrier**; finished items proceed immediately |
+| `phase(title)` | Mark the current progress phase and update the progress display |
+| `log(message)` | Emit a progress log line |
+| `workflow(name, args)` | Run a nested sub-workflow, one level only |
+
+`pipeline` should be the default. Each item independently crosses every stage. Item A may reach stage three while item B is still in stage one. Use the `parallel` barrier only when the next stage truly requires every result from the previous stage. A barrier waits for the slowest task, so do not add one without need.
+
+```python
+async def pipeline(self, items, *stages):
+ async def run_item(item, idx):
+ value = item
+ for stage in stages: # Each item independently completes every stage
+ value = await stage(value, item, idx)
+ return value
+ return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
+```
+
+> The real Claude Code injects same-named primitives into the script VM. It also exposes `args`, `budget` with total/spent/remaining values, an agent limit of up to 1000, and a concurrency semaphore.
+
+## Structured Output: Do Not Let Subagents Return Essays
+
+`agent({schema})` requires a subagent to return a JSON object matching the schema, internally through one structured-output call. The runtime validates the result and retries once if it does not match. Downstream code receives a regular object instead of a long essay that must be parsed again.
+
+s05 warned that tool arguments cannot be trusted completely. This is the same lesson in reverse: subagent output cannot be trusted completely either. Validate at the orchestration boundary, give one retry, and keep uncertainty out of the rest of the flow.
+
+```python
+result = self.runner.run(prompt, schema, label)
+if schema is not None:
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok: # Retry once with a reminder, then fail
+ result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label)
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok:
+ raise WorkflowInputError(f"agent({{schema}}) returned invalid output: {err}")
+```
+
+> The real Claude Code combines `SimpleJsonSchema`, a `StructuredOutput` tool, and schema-aware retries to enforce the output format.
+
+## Background Tasks and Progress Events
+
+`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.
+
+The main session treats these as ordinary events. Only the final completion notification re-enters the main loop.
+
+```python
+class LocalWorkflowTask:
+ def progress_event(self, ptype, **data): # Phase/subagent/log
+ self.progress.append({"type": ptype, **data})
+ print(f" progress {ptype} ...")
+```
+
+> The real Claude Code folds progress into task state and sends it to the UI and SDK as `task_progress.workflow_progress`.
+
+## Storage: Snapshot + Journal for Resuming after Interruptions
+
+Each run writes five artifacts under `~/.claude/projects///`: a `.json` snapshot, `.output.json` output, `.journal.jsonl` journal, a `scripts/.js` script copy, and subagent transcripts under `subagents/workflows//`. Reusable workflows that you save live in `.claude/workflows/` at project scope or `~/.claude/workflows/` at user scope.
+
+The journal is the core of checkpointed resume. It records every `agent()` result one line at a time:
+
+```python
+class WorkflowJournal:
+ def record(self, key, value):
+ self._f.write(json.dumps({"key": key, "value": value}) + "\n")
+ self._f.flush()
+ self.cache[key] = value
+```
+
+## Resume: Continue by runId and Reuse Everything Unchanged
+
+Calling `Workflow({scriptPath, resumeFromRunId, args})` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.
+
+The key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If "the nth completion" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:
+
+```python
+def key(self, kind, label, prompt, schema):
+ basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
+ return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
+
+# Inside agent():
+cached = self.journal.cached(key)
+if cached is not MISS:
+ self.task.progress_event("workflow_agent", label=label, status="cached")
+ return cached
+```
+
+> The real Claude Code uses the same idea: deterministic semantic keys plus a journal cache. Resuming within the same session returns cached results for completed `agent()` calls and runs only the remaining ones.
+
+## Determinism: Reproducibility Makes Resume Meaningful
+
+Resume works only if the script is reproducible. The runtime therefore removes nondeterministic sources such as `Date.now()`, no-argument `new Date()`, and `Math.random()` from the script context, and does not expose native Node APIs. The same script plus the same arguments produces the same keys and a 100% cache hit. The teaching version obtains the same property through stable key hashing; the real version runs the entire JavaScript inside a sandboxed VM with those sources removed.
+
+## See It Run
+
+The sample `review-changes` workflow uses `pipeline` to send each review dimension independently through audit → verify. An `agent()` with a schema finds issues during audit. During verification, `parallel()` dispatches a separate adversarial subagent for every finding. Only confirmed issues remain, sorted by severity.
+
+```python
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+
+ async def audit(_v, dimension, _i):
+ out = await ctx.agent(f"Inspect the changed code for {dimension} issues",
+ schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
+ return {"dimension": dimension, "findings": out["findings"]}
+
+ async def verify(audited, dimension, _i):
+ ctx.phase("Verify")
+ verdicts = await ctx.parallel([ # Verify every finding independently
+ (lambda f=f: ctx.agent(f"Adversarially verify whether this issue is real: {f['title']}",
+ schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
+ for f in audited["findings"]])
+ return {"dimension": dimension,
+ "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
+
+ results = await ctx.pipeline(DIMENSIONS, audit, verify)
+ ...
+```
+
+## Changes from s20
+
+| | s20 Comprehensive Agent | s21 Workflow Runtime |
+|--|-----------|---------------------|
+| Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it |
+| Who decides the next step | Model decides each round | Script declares the orchestration in advance |
+| Multiple agents | One-shot s06 subagents | Scripted, reproducible, recoverable bulk orchestration |
+| New mechanisms | — | Script DSL, background tasks, progress events, journal/resume, structured output, deterministic VM |
+
+s21 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s21 turns orchestration into a replayable script.
+
+## Try It
+
+```bash
+python s21_workflow_runtime/code.py # Start review-changes and watch the event stream
+python s21_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache
+```
+
+Watch one launch produce `async_launched`, followed by background phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.
+
+## Next
+
+Orchestration adds a layer above agent capabilities: the main loop handles individual operations, while a script manages the whole team's flow. Once work becomes a deterministic, recoverable script, the model changes from the round-by-round driver into an execution unit scheduled by that script. The same `agent()` can be invoked ad hoc by the model in the main loop or orchestrated in bulk inside a workflow.
+
+Next: [s22 Goal Loop](../s22_goal_loop/) — Orchestration fans work out and leaves the main loop. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.
+
+
diff --git a/s21_workflow_runtime/README.zh.md b/s21_workflow_runtime/README.zh.md
new file mode 100644
index 00000000..9a58ab6e
--- /dev/null
+++ b/s21_workflow_runtime/README.zh.md
@@ -0,0 +1,242 @@
+# s21: Workflow Runtime — 模型决定单步,脚本决定编排
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
+
+> *"一次 tool_use,后台跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
+>
+> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
+
+> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制。
+>
+> 教学 CLI 会先发出 `async_launched`,随后在同一进程等待完成,以保证输出可复现。它演示的是生命周期与 journal,不是并发运行的主循环。
+
+---
+
+从 s01 到 s20,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
+
+但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:
+
+- **并行**,别一个一个串着等;
+- **确定**,同样的输入跑出来同样的结果结构;
+- **可恢复**,跑到一半断了,已经做完的部分别从头再来。
+
+让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是"再聊一轮",而是把这套编排直接写成代码。
+
+## 计划写在代码里,不是靠聊天一轮轮凑
+
+Claude Code 在工具池里放了一个 `Workflow` 工具。你(或者模型在高强度模式下触发)给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
+
+主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resumeFromRunId` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
+
+
+
+```python
+SAMPLE_META = {"name": "review-changes", "description": "审查代码改动", "phases": ["Review", "Verify"]}
+
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+ results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证
+ confirmed = [f for r in results if r for f in r["confirmed"]]
+ ctx.log(f"确认了 {len(confirmed)} 个真实问题")
+ return {"confirmed": confirmed}
+```
+
+## Workflow 工具:后台启动,主循环只看到一次调用
+
+`Workflow`(别名 `RunWorkflow`)就在主 agent 的工具池里。触发可能来自你显式说"跑一下这个 workflow"、一个保存好的 `/命令`,或者模型自动进入高强度路径,这时候模型会发一个 `Workflow(...)` 的工具调用。
+
+工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回"已异步启动"。主循环不阻塞,该干嘛干嘛;workflow 自己在后台跑。这其实就是 s13 后台任务那套"凭条模式"的放大版:先给你个取件条,结果好了再通知你。
+
+```python
+class WorkflowTool:
+ async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
+ validate_meta(meta)
+ check_permission(meta)
+ run_id = resume_from_run_id or create_run_id(meta)
+ task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
+ task.event("async_launched", runId=run_id, taskId=task.task_id) # 立刻返回
+ ... # 剩下的后台慢慢跑
+```
+
+> 真实 Claude Code:工具会立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务跑完了再通知。
+
+## 脚本和 meta:第一行必须写对
+
+脚本的第一行必须是 `export const meta = { name, description, phases }`,而且必须是纯字面量,不能有变量、函数调用、字符串拼接。运行时在执行任何代码之前先解析它:`name` 和 `description` 用来显示任务和 UI,`phases` 给进度条分组命名。
+
+不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。
+
+教学运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。
+
+```python
+def validate_meta(meta):
+ if not isinstance(meta, dict):
+ raise WorkflowInputError("meta 必须是对象字面量")
+ if not meta.get("name") or not meta.get("description"):
+ raise WorkflowInputError("meta 必须包含 name 和 description")
+ if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
+ raise WorkflowInputError("meta.name 必须是 1-64 字符的安全 slug")
+ if "phases" in meta and (
+ not isinstance(meta["phases"], list)
+ or not all(isinstance(p, str) and p for p in meta["phases"])
+ ):
+ raise WorkflowInputError("meta.phases 必须包含非空字符串")
+ return meta
+```
+
+> 真实 Claude Code:`parseWorkflowScript` 强制 meta 必须是第一行且是纯字面量;教学版直接收一个 dict,简化了这部分。
+
+## 编排原语:就这几个,够写所有流程
+
+脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell,真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:
+
+| 原语 | 作用 |
+|------|------|
+| `agent(prompt, {schema, label, phase})` | 派一个子 agent 干活 |
+| `parallel(thunks)` | **等齐屏障**:所有任务并行跑完,一起等结果回来 |
+| `pipeline(items, *stages)` | 每个 item 分阶段跑,**不等齐**,跑完一个往下走一个 |
+| `phase(title)` | 标记当前进度阶段(更新进度条) |
+| `log(message)` | 打一行进度日志 |
+| `workflow(name, args)` | 嵌套子工作流(只支持一层) |
+
+`pipeline` 是你默认该用的:每个 item 独立穿过所有 stage,item A 跑到第 3 阶段的时候,item B 可能还在第 1 阶段;只有真的需要"拿到上一阶段所有结果才能往下走"的时候,才用 `parallel` 这个屏障。屏障的代价是等最慢的那个任务,没必要就别立。
+
+```python
+async def pipeline(self, items, *stages):
+ async def run_item(item, idx):
+ value = item
+ for stage in stages: # 每个 item 独立跑完所有 stage
+ value = await stage(value, item, idx)
+ return value
+ return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
+```
+
+> 真实 Claude Code:同名原语由 VM 注入脚本上下文;还提供 `args`、`budget`(总预算/已花/剩余)、agent 数量上限(最多 1000 个)、并发信号量这些控制。
+
+## 结构化输出:别让子 agent 回来写散文
+
+`agent({schema})` 会强制子 agent 返回一个匹配 schema 的 JSON 对象(内部通过一次结构化输出调用实现),运行时会按 schema 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。
+
+s05 就说过,工具的参数不能全信;这里是同一个道理反过来:子 agent 的输出也不能全信。加一层校验,不对就给一次机会重试,把不确定性挡在编排层外面。
+
+```python
+result = self.runner.run(prompt, schema, label)
+if schema is not None:
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok: # 提醒一次重试,再不对就报错
+ result = self.runner.run(prompt + "\n\n返回合法的 JSON。", schema, label)
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok:
+ raise WorkflowInputError(f"agent({{schema}}) 输出不合法: {err}")
+```
+
+> 真实 Claude Code:用 `SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试机制保证输出格式。
+
+## 后台任务和进度事件
+
+`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止,带输出文件、token 数、工具调用数、耗时)。
+
+主会话把这些当普通事件处理;只有最终的完成通知会重新进入主循环。
+
+```python
+class LocalWorkflowTask:
+ def progress_event(self, ptype, **data): # 阶段/子agent/日志
+ self.progress.append({"type": ptype, **data})
+ print(f" 进度 {ptype} ...")
+```
+
+> 真实 Claude Code:进度会折叠进任务状态,作为 `task_progress.workflow_progress` 发给 UI 和 SDK。
+
+## 存储:快照 + journal,断了能续
+
+跑完会写五样东西,都存在 `~/.claude/projects/<项目>/<会话>/` 目录下:快照 `.json`、输出 `.output.json`、journal `.journal.jsonl`、脚本副本 `scripts/.js`、子 agent 的对话记录 `subagents/workflows//`。你自己保存的常用 workflow 放在 `.claude/workflows/`(项目级)或 `~/.claude/workflows/`(用户级)。
+
+journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
+
+```python
+class WorkflowJournal:
+ def record(self, key, value):
+ self._f.write(json.dumps({"key": key, "value": value}) + "\n")
+ self._f.flush()
+ self.cache[key] = value
+```
+
+## resume:用 runId 续跑,没改的直接用缓存
+
+调用 `Workflow({scriptPath, resumeFromRunId, args})` 会重新跑脚本,但每个 `agent()` 会算一个确定的语义 key:key 在 journal 里有记录,就直接返回缓存的结果(不重跑),没改过的全部命中缓存;只有改过的那个以及它后面的步骤才会真的跑。
+
+这里有个关键点:key 不能依赖并发顺序。`parallel` 和 `pipeline` 里 agent 完成的顺序是不确定的,用"第几个完成"当 key,两次跑缓存就对错位了。所以 key 是根据调用内容(类型、标签、prompt、schema)算的稳定哈希,不是一个会竞争的计数器:
+
+```python
+def key(self, kind, label, prompt, schema):
+ basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
+ return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
+
+# agent() 内部:
+cached = self.journal.cached(key)
+if cached is not MISS:
+ self.task.progress_event("workflow_agent", label=label, status="cached")
+ return cached
+```
+
+> 真实 Claude Code:同样是"确定语义 key + journal 缓存"的思路;同会话内续跑时,已经完成的 `agent()` 直接返回缓存,后面的才实跑。
+
+## 确定性:能复现,续跑才有意义
+
+续跑要能工作,脚本首先得可复现。所以运行时会把 `Date.now()`、无参 `new Date()`、`Math.random()` 这些不确定的东西从脚本上下文里去掉,也不给 Node 原生 API。同一份脚本 + 同样的参数 → 同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 达到同样的效果(真实版是把整段 JS 脚本跑在去掉了这些不确定源的沙箱 VM 里)。
+
+## 跑起来看看
+
+示例 workflow `review-changes`:用 `pipeline` 让每个审查维度独立走"审计 → 验证"流程。审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证的子 agent,最后只留确认真实的问题,按严重度排序。
+
+```python
+async def sample_workflow(ctx, args):
+ ctx.phase("Review")
+
+ async def audit(_v, dimension, _i):
+ out = await ctx.agent(f"检查改动的代码里有没有{dimension}相关的问题",
+ schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
+ return {"dimension": dimension, "findings": out["findings"]}
+
+ async def verify(audited, dimension, _i):
+ ctx.phase("Verify")
+ verdicts = await ctx.parallel([ # 每条发现独立做对抗性验证
+ (lambda f=f: ctx.agent(f"请对抗性验证这个问题是不是真的:{f['title']}",
+ schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}"))
+ for f in audited["findings"]])
+ return {"dimension": dimension,
+ "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]}
+
+ results = await ctx.pipeline(DIMENSIONS, audit, verify)
+ ...
+```
+
+## 相对 s20 的变更
+
+| | s20 综合体 | s21 Workflow Runtime |
+|--|-----------|---------------------|
+| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
+| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
+| 多 agent | s06 子 agent,一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
+| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |
+
+s21 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次;s21 是把编排写成可以重放的脚本。
+
+## 试一下
+
+```bash
+python s21_workflow_runtime/code.py # 启动 review-changes,看事件流
+python s21_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
+```
+
+观察:一次启动 → `async_launched` → 后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
+
+## 接下来
+
+编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。
+
+下一章:[s22 Goal Loop](../s22_goal_loop/) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。
+
+
diff --git a/s21_workflow_runtime/code.py b/s21_workflow_runtime/code.py
new file mode 100644
index 00000000..108b54b2
--- /dev/null
+++ b/s21_workflow_runtime/code.py
@@ -0,0 +1,566 @@
+"""
+s21_workflow_runtime — Dynamic Workflow runtime (teaching version)
+
+Clean-room behavioral reconstruction of Claude Code's `Workflow` tool / dynamic
+workflow runtime. Grounded in @anthropic-ai/claude-code@2.1.177 observed
+behavior (reverse-research/cc_workflow), NOT leaked source.
+
+Idea:
+ s01-s20 build a single, model-driven agent loop. s21 adds a deterministic
+ orchestration LAYER on top: the main loop exposes a `Workflow` tool that
+ launches a background runtime; a script written with agent()/parallel()/
+ pipeline()/phase() drives many subagents deterministically, reports progress,
+ persists a journal, and can resume from a runId.
+
+Run:
+ python code.py # run the sample workflow, print the event stream
+ python code.py resume # resume the last run; unchanged agent() calls hit cache
+
+Teaching simplifications (vs real runtime.mjs):
+ - The "subagent" is a deterministic MockAgentRunner, not a real LLM.
+ - A workflow is a plain async Python function, not a sandboxed JS script
+ string. The real runtime runs the script in an isolated JS VM with
+ Date.now()/Math.random() removed so resume is reproducible.
+ - The CLI emits `async_launched` and then awaits completion so the demo stays
+ deterministic. The real tool returns while execution continues in background.
+ - Storage is a local .runtime/ dir instead of ~/.claude/projects/.../workflows/.
+"""
+
+import asyncio
+import hashlib
+import json
+import re
+import sys
+from pathlib import Path
+
+# ---- knobs that mirror the real runtime's guards ----
+AGENT_CAP = 1000 # hard cap on agent() calls per run
+CONCURRENCY = 8 # parallelism cap (semaphore)
+STORE = Path(__file__).parent / ".runtime" # snapshots + journals live here
+MISS = object() # journal cache miss sentinel
+WORKFLOW_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
+RUN_ID_RE = re.compile(r"^wf_[A-Za-z0-9][A-Za-z0-9._-]{0,63}_[0-9]{4}$")
+
+
+def _stable_hash(s: str) -> int:
+ """Process-stable hash (Python's hash() is salted per process, which would
+ break resume keys across `run` and `resume`)."""
+ return int(hashlib.sha256(s.encode()).hexdigest(), 16)
+
+
+def create_run_id(meta) -> str:
+ # Deterministic in the teaching version so the journal path is predictable
+ # and `resume` lands on the same file. The real runtime mints a random id.
+ return f"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}"
+
+
+def create_task_id(run_id) -> str:
+ return f"local_workflow_{run_id}"
+
+
+def validate_run_id(run_id):
+ if not isinstance(run_id, str) or not RUN_ID_RE.fullmatch(run_id):
+ raise WorkflowInputError("invalid workflow runId")
+ return run_id
+
+
+# ============================================================
+# Errors
+# ============================================================
+class WorkflowInputError(Exception):
+ """Bad script / meta / schema input (mirrors WorkflowInputError)."""
+
+
+# ============================================================
+# meta validation
+# ============================================================
+def validate_meta(meta):
+ """Real runtime requires `export const meta = {...}` as the FIRST statement,
+ a pure literal, with name + description (+ optional phases). We take a dict."""
+ if not isinstance(meta, dict):
+ raise WorkflowInputError("meta must be an object literal")
+ if not meta.get("name") or not meta.get("description"):
+ raise WorkflowInputError("meta requires `name` and `description`")
+ if not isinstance(meta["name"], str) or not WORKFLOW_NAME_RE.fullmatch(meta["name"]):
+ raise WorkflowInputError(
+ "meta.name must be a 1-64 character slug using letters, numbers, '.', '_', or '-'"
+ )
+ if not isinstance(meta["description"], str):
+ raise WorkflowInputError("meta.description must be a string")
+ if "phases" in meta:
+ if not isinstance(meta["phases"], list) or not all(
+ isinstance(phase, str) and phase for phase in meta["phases"]
+ ):
+ raise WorkflowInputError("meta.phases must be a list of non-empty strings")
+ return meta
+
+
+def check_permission(meta, settings=None):
+ """allow / deny / ask gate before launch (s03 permission system, applied to
+ Workflow). Teaching version allows by default; a deny rule blocks."""
+ settings = settings or {}
+ if meta["name"] in settings.get("deny", []):
+ raise WorkflowInputError(f"workflow '{meta['name']}' denied by settings")
+ return "allow"
+
+
+# ============================================================
+# Minimal JSON-schema for structured output (SimpleJsonSchema)
+# ============================================================
+class SimpleJsonSchema:
+ """Tiny validator backing agent({schema}). Just enough for teaching:
+ object/array/string/boolean/number + required keys."""
+
+ def __init__(self, schema):
+ self.schema = schema
+
+ def validate(self, value, schema=None):
+ schema = self.schema if schema is None else schema
+ t = schema.get("type")
+ if t == "object":
+ if not isinstance(value, dict):
+ return False, "expected object"
+ for key in schema.get("required", []):
+ if key not in value:
+ return False, f"missing required key '{key}'"
+ for key, sub in schema.get("properties", {}).items():
+ if key in value:
+ ok, err = self.validate(value[key], sub)
+ if not ok:
+ return False, f"{key}: {err}"
+ return True, None
+ if t == "array":
+ if not isinstance(value, list):
+ return False, "expected array"
+ items = schema.get("items")
+ if items:
+ for i, el in enumerate(value):
+ ok, err = self.validate(el, items)
+ if not ok:
+ return False, f"[{i}]: {err}"
+ return True, None
+ if t == "string":
+ return (isinstance(value, str), None if isinstance(value, str) else "expected string")
+ if t == "boolean":
+ return (isinstance(value, bool), None if isinstance(value, bool) else "expected boolean")
+ if t in ("number", "integer"):
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool)
+ return (ok, None if ok else "expected number")
+ return True, None
+
+
+def _fill_schema(schema, seed):
+ """Deterministic generic filler used for schemas the mock doesn't special-case."""
+ t = schema.get("type")
+ if t == "object":
+ keys = schema.get("required") or list(schema.get("properties", {}))
+ return {k: _fill_schema(schema["properties"][k], f"{seed}/{k}") for k in keys}
+ if t == "array":
+ return [_fill_schema(schema["items"], f"{seed}/0")]
+ if t == "boolean":
+ return _stable_hash(seed) % 4 != 0
+ if t in ("number", "integer"):
+ return _stable_hash(seed) % 5
+ return seed.rsplit("/", 1)[-1]
+
+
+# ============================================================
+# Subagent runner (mock for teaching; real path = an LLM tool loop)
+# ============================================================
+class MockAgentRunner:
+ """Stands in for a spawned subagent. Deterministic so resume is reproducible.
+ A real runner would run an isolated agent loop that calls repo tools and is
+ forced to emit StructuredOutput when a schema is present."""
+
+ def run(self, prompt, schema=None, label=None):
+ if schema is None:
+ return f"[mock] {(label or prompt)[:60]}"
+ props = schema.get("properties", {})
+ if "findings" in props: # an audit agent
+ n = 1 + (_stable_hash(prompt) % 2) # 1-2 findings
+ sev = ["high", "medium", "low"]
+ return {"findings": [
+ {"title": f"{label or 'audit'} #{i + 1}",
+ "severity": sev[_stable_hash(prompt + str(i)) % 3]}
+ for i in range(n)
+ ]}
+ if "isReal" in props: # a verifier agent
+ real = _stable_hash(prompt) % 4 != 0 # ~75% confirmed
+ return {"isReal": real,
+ "reason": "reproduced" if real else "could not reproduce"}
+ return _fill_schema(schema, prompt)
+
+ @staticmethod
+ def tokens(prompt, result):
+ return len(prompt) // 4 + len(json.dumps(result, default=str)) // 4
+
+
+# ============================================================
+# Journal (resume cache): started/result per agent under a semantic key
+# ============================================================
+class WorkflowJournal:
+ """Append-only .journal.jsonl. On resume, agent() calls whose
+ semantic key is already present are replayed from cache instead of re-run."""
+
+ def __init__(self, run_id, resume, store=STORE):
+ store.mkdir(parents=True, exist_ok=True)
+ self.path = store / f"{run_id}.journal.jsonl"
+ self.resume = resume
+ self.cache = {}
+ if resume:
+ if not self.path.exists():
+ raise WorkflowInputError(f"resume journal not found for {run_id}")
+ for line_number, line in enumerate(self.path.read_text().splitlines(), start=1):
+ try:
+ rec = json.loads(line)
+ if (
+ not isinstance(rec, dict)
+ or not isinstance(rec.get("key"), str)
+ or "value" not in rec
+ ):
+ raise ValueError("expected key/value record")
+ except (json.JSONDecodeError, ValueError) as exc:
+ raise WorkflowInputError(
+ f"invalid resume journal record at line {line_number}"
+ ) from exc
+ self.cache[rec["key"]] = rec["value"]
+ self._f = self.path.open("a")
+ else:
+ self._f = self.path.open("w") # fresh run truncates
+
+ def key(self, kind, label, prompt, schema):
+ # Deterministic semantic key — independent of concurrency order, so a
+ # parallel/pipeline call gets the same key on resume.
+ basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}"
+ return f"{kind}-{_stable_hash(basis) % 10**10:010d}"
+
+ def cached(self, key):
+ return self.cache.get(key, MISS)
+
+ def record(self, key, value):
+ self._f.write(json.dumps({"key": key, "value": value}) + "\n")
+ self._f.flush()
+ self.cache[key] = value
+
+ def close(self):
+ self._f.close()
+
+
+# ============================================================
+# Token budget
+# ============================================================
+class Budget:
+ """budget.total / spent() / remaining(). Once spent reaches total, agent()
+ calls raise (the real runtime enforces the same ceiling)."""
+
+ def __init__(self, total=None):
+ self.total = total
+ self._spent = 0
+
+ def add(self, n):
+ if self.total is not None and self._spent + n > self.total:
+ raise WorkflowInputError(
+ f"token budget exceeded ({self._spent + n} > {self.total})"
+ )
+ self._spent += n
+
+ def spent(self):
+ return self._spent
+
+ def remaining(self):
+ return float("inf") if self.total is None else max(0, self.total - self._spent)
+
+
+# ============================================================
+# Background task state + progress events (the outer event stream)
+# ============================================================
+class LocalWorkflowTask:
+ """type local_workflow. Holds status/usage and emits the SDK-like event
+ stream: task_started, task_progress (workflow_phase/agent/log), task_notification."""
+
+ def __init__(self, task_id, run_id, meta):
+ self.task_id = task_id
+ self.run_id = run_id
+ self.meta = meta
+ self.status = "running"
+ self.usage = {"agents": 0, "tokens": 0}
+ self.progress = []
+
+ def event(self, name, **data):
+ line = " ".join(f"{k}={v}" for k, v in data.items())
+ print(f" event {name:<18} {line}")
+
+ def progress_event(self, ptype, **data):
+ self.progress.append({"type": ptype, **data})
+ line = " ".join(f"{k}={v}" for k, v in data.items())
+ print(f" progress {ptype:<16} {line}")
+
+
+# ============================================================
+# ExecutionState: the DSL the workflow script sees as `ctx`
+# ============================================================
+class ExecutionLimits:
+ """Shared run-wide limits, including nested workflows."""
+
+ def __init__(self):
+ self.agents = 0
+ self.semaphore = asyncio.Semaphore(CONCURRENCY)
+
+ def claim_agent(self):
+ self.agents += 1
+ if self.agents > AGENT_CAP:
+ raise WorkflowInputError(f"agent() cap reached ({AGENT_CAP})")
+
+
+class ExecutionState:
+ """Injected into the workflow script. Provides the orchestration primitives.
+ Mirrors ExecutionState in runtime.mjs."""
+
+ def __init__(self, task, journal, runner, budget, args, depth=0, limits=None):
+ self.task = task
+ self.journal = journal
+ self.runner = runner
+ self.budget = budget
+ self.args = args
+ self._depth = depth
+ self._phase = None
+ self._phases_seen = set()
+ self._limits = limits or ExecutionLimits()
+
+ def phase(self, title):
+ """Start a phase; subsequent agent()s group under it. Upsert: emitting the
+ same phase again (e.g. from each pipeline item) does not re-announce it."""
+ self._phase = title
+ if title not in self._phases_seen:
+ self._phases_seen.add(title)
+ self.task.progress_event("workflow_phase", title=title)
+
+ def log(self, message):
+ """Emit a workflow_log progress line."""
+ self.task.progress_event("workflow_log", message=message)
+
+ async def agent(self, prompt, schema=None, label=None, phase=None):
+ """Spawn one subagent. With a schema, force StructuredOutput + validate
+ (retry once). On resume, a cached key short-circuits the run."""
+ label = label or (prompt[:24] + "…")
+ self._limits.claim_agent()
+ if self.budget.remaining() <= 0:
+ raise WorkflowInputError("token budget exceeded")
+
+ key = self.journal.key("agent", label, prompt, schema)
+ cached = self.journal.cached(key)
+ if cached is not MISS:
+ if schema is not None:
+ ok, err = SimpleJsonSchema(schema).validate(cached)
+ if not ok:
+ raise WorkflowInputError(
+ f"cached agent output failed schema validation: {err}"
+ )
+ self.task.progress_event("workflow_agent", label=label,
+ phase=phase or self._phase, status="cached")
+ return cached
+
+ async with self._limits.semaphore:
+ await asyncio.sleep(0) # yield: real subagents are async
+ result = self.runner.run(prompt, schema, label)
+
+ if schema is not None:
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok: # one nudge/retry, then fail
+ result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label)
+ ok, err = SimpleJsonSchema(schema).validate(result)
+ if not ok:
+ raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}")
+
+ toks = self.runner.tokens(prompt, result)
+ self.budget.add(toks)
+ self.task.usage["agents"] += 1
+ self.task.usage["tokens"] += toks
+ self.journal.record(key, result)
+ self.task.progress_event("workflow_agent", label=label,
+ phase=phase or self._phase, status="done")
+ return result
+
+ async def parallel(self, thunks):
+ """BARRIER: run all thunks concurrently and fail if any thunk fails."""
+ return await asyncio.gather(*[thunk() for thunk in thunks])
+
+ async def pipeline(self, items, *stages):
+ """Per-item staged flow, NO barrier between stages: item A can be in
+ stage 3 while item B is still in stage 1. Each stage gets
+ (prev_result, original_item, index). A throwing stage fails the workflow."""
+ async def run_item(item, idx):
+ value = item
+ for stage in stages:
+ value = await stage(value, item, idx)
+ return value
+ return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
+
+ async def workflow(self, name, args=None):
+ """Run a saved workflow inline as a child (one level), sharing this run's
+ journal + budget + agent counter."""
+ if self._depth >= 1:
+ raise WorkflowInputError("workflow() nesting is one level only")
+ if name not in WORKFLOWS:
+ raise WorkflowInputError(f"unknown workflow '{name}'")
+ meta, fn = WORKFLOWS[name]
+ child = ExecutionState(self.task, self.journal, self.runner, self.budget,
+ args or {}, depth=self._depth + 1,
+ limits=self._limits)
+ return await fn(child, args or {})
+
+
+# ============================================================
+# WorkflowTool: the tool entry (WorkflowTool.call)
+# ============================================================
+class WorkflowTool:
+ """The Workflow tool. .call() validates meta, runs the permission check,
+ creates runId/taskId, registers a LocalWorkflowTask, and emits the same
+ lifecycle while this teaching CLI awaits the final result. Supports
+ resumeFromRunId. Mirrors WorkflowTool.call in runtime.mjs."""
+
+ async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
+ validate_meta(meta)
+ check_permission(meta)
+ args = args or {}
+ run_id = resume_from_run_id or create_run_id(meta)
+ validate_run_id(run_id)
+ if resume_from_run_id is not None and run_id != create_run_id(meta):
+ raise WorkflowInputError("resume runId does not match workflow meta")
+ task_id = create_task_id(run_id)
+ resuming = resume_from_run_id is not None
+
+ task = LocalWorkflowTask(task_id, run_id, meta)
+ # The real tool returns this immediately and runs the rest in background.
+ launched = {"status": "async_launched", "taskId": task_id,
+ "taskType": "local_workflow", "runId": run_id,
+ "workflowName": meta["name"]}
+ task.event("async_launched", runId=run_id, taskId=task_id)
+ task.event("task_started", workflow=meta["name"],
+ phases=",".join(meta.get("phases", [])) or "-",
+ resume=resuming)
+
+ journal = None
+ try:
+ journal = WorkflowJournal(run_id, resume=resuming)
+ ctx = ExecutionState(
+ task, journal, MockAgentRunner(), Budget(args.get("budget")), args
+ )
+ result = await script_fn(ctx, args)
+ task.status = "completed"
+ except Exception as e: # failed / stopped close the loop too
+ task.status = "failed"
+ result = {"error": str(e)}
+ finally:
+ if journal is not None:
+ journal.close()
+
+ _write_json(STORE / f"{run_id}.output.json", result)
+ _save_last_run(run_id)
+ task.event("task_notification", status=task.status,
+ agents=task.usage["agents"], tokens=task.usage["tokens"],
+ outputFile=f".runtime/{run_id}.output.json")
+ return {"launched": launched, "result": result, "task": task}
+
+
+def _write_json(path, value):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, indent=2, default=str))
+
+
+def _save_last_run(run_id):
+ (STORE / "last_run.txt").write_text(run_id)
+
+
+def _read_last_run():
+ p = STORE / "last_run.txt"
+ return p.read_text().strip() if p.exists() else None
+
+
+# ============================================================
+# Sample workflow: review changed code across dimensions, verify each finding.
+# Mirrors cc_workflow/runtime/workflows/review_workflow.js (pipeline + parallel).
+# ============================================================
+FINDINGS_SCHEMA = {
+ "type": "object", "required": ["findings"],
+ "properties": {"findings": {"type": "array", "items": {
+ "type": "object", "required": ["title", "severity"],
+ "properties": {"title": {"type": "string"}, "severity": {"type": "string"}}}}},
+}
+VERDICT_SCHEMA = {
+ "type": "object", "required": ["isReal", "reason"],
+ "properties": {"isReal": {"type": "boolean"}, "reason": {"type": "string"}},
+}
+
+SAMPLE_META = {
+ "name": "review-changes",
+ "description": "Review changed files across dimensions, verify each finding",
+ "phases": ["Review", "Verify"],
+}
+
+DIMENSIONS = ["correctness", "security", "performance", "style"]
+
+
+async def sample_workflow(ctx, args):
+ """pipeline over review dimensions (audit -> verify-each), then keep only the
+ findings a verifier confirms. The plan is code, not a chat turn."""
+ ctx.phase("Review")
+
+ async def audit(_value, dimension, _idx):
+ out = await ctx.agent(
+ f"Review the changed files for {dimension} issues.",
+ schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review")
+ return {"dimension": dimension, "findings": out["findings"]}
+
+ async def verify(audited, dimension, _idx):
+ ctx.phase("Verify")
+ # Each finding is verified by its own adversarial subagent, concurrently.
+ verdicts = await ctx.parallel([
+ (lambda f=f: ctx.agent(
+ f"Adversarially verify this {dimension} finding — is it real? {f['title']}",
+ schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}", phase="Verify"))
+ for f in audited["findings"]])
+ confirmed = [f for f, v in zip(audited["findings"], verdicts)
+ if v and v.get("isReal")]
+ return {"dimension": dimension, "confirmed": confirmed}
+
+ results = await ctx.pipeline(DIMENSIONS, audit, verify)
+ confirmed = [{"dimension": r["dimension"], **f}
+ for r in results if r for f in r["confirmed"]]
+ confirmed.sort(key=lambda f: {"high": 0, "medium": 1, "low": 2}.get(f["severity"], 3))
+ ctx.log(f"confirmed {len(confirmed)} real finding(s)")
+ return {"confirmed": confirmed}
+
+
+# saved workflow registry (.claude/workflows/ analogue)
+WORKFLOWS = {SAMPLE_META["name"]: (SAMPLE_META, sample_workflow)}
+
+
+# ============================================================
+# Demo
+# ============================================================
+async def main(argv):
+ resume_id = None
+ if argv and argv[0] == "resume":
+ resume_id = _read_last_run()
+ if not resume_id:
+ print("nothing to resume — run `python code.py` first.")
+ return
+ print(f"resuming {resume_id} — unchanged agent() calls hit the journal cache\n")
+ else:
+ print("launching workflow `review-changes`\n")
+
+ tool = WorkflowTool()
+ out = await tool.call(SAMPLE_META, sample_workflow,
+ args={"budget": None}, resume_from_run_id=resume_id)
+
+ print("\nresult:")
+ for f in out["result"].get("confirmed", []):
+ print(f" [{f['severity']:<6}] {f['dimension']}: {f['title']}")
+ t = out["task"]
+ print(f"\nstatus={t.status} agents={t.usage['agents']} tokens={t.usage['tokens']}"
+ f" journal=.runtime/{t.run_id}.journal.jsonl")
+
+
+if __name__ == "__main__":
+ asyncio.run(main(sys.argv[1:]))
diff --git a/s21_workflow_runtime/images/workflow-runtime-overview.svg b/s21_workflow_runtime/images/workflow-runtime-overview.svg
new file mode 100644
index 00000000..281cd2e3
--- /dev/null
+++ b/s21_workflow_runtime/images/workflow-runtime-overview.svg
@@ -0,0 +1,120 @@
+
diff --git a/s22_goal_loop/README.ja.md b/s22_goal_loop/README.ja.md
new file mode 100644
index 00000000..3d5bbef4
--- /dev/null
+++ b/s22_goal_loop/README.ja.md
@@ -0,0 +1,164 @@
+# s22: Goal Loop — いつ止まるかはモデルではなく goal が決める
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s20 → s21 → `s22`
+
+> *「turn が終了できるかは goal condition を満たすかで決まり、モデルが stop と言っただけでは終わらない」* — `/goal` は main loop の各 turn の終端に gate を追加します。独立した evaluator が trusted evidence の充足を確認し、不足ならモデルを次のラウンドへ押し戻します。
+>
+> **Harness 層**: Goal closure — turn 終端に program-controlled completion gate を追加します。
+
+> **情報源の境界:** この章の製品詳細は Claude Code 2.1.177 の clean-room 行動再構成に基づく。後続リリースで名称や制限は変わり得る。`code.py` はオフライン教材モデルであり、製品ソースの複製ではない。
+
+---
+
+s01 から s21 まで、会話の 1 turn はどう終わったでしょうか。モデルが `tool_use` を出さなくなると、loop はそのまま `return` しました。one-shot task なら問題ありません。終わったら止まります。
+
+しかし「テストを通す」「deploy が成功するまで続ける」のように、最後まで見届けるべき goal もあります。そこでは 2 つの問題がよく起きます。モデルが途中まで進めて十分だと思い、自分で止まる。さらに悪ければ、口頭で `tests passed` と言うだけで終了しようとします。必要なことは単純です。turn が終了できるかをモデル自身に決めさせず、明示的な condition を実際の evidence に照らして判断します。
+
+この流れは最初の章からありました。s01 は loop の exit がモデルの判断だと説明し、s04 の Stop hook が初めて program に veto を与えました。この章は、その veto を condition、evidence、budget の 3 要素が欠けない完全な loop にします。
+
+## /goal: 各 turn の終端に gate を追加する
+
+`/goal ` を入力すると session-scoped stopping condition を設定します。program は active goal として保存し、各 turn の後に独立した lightweight model を evaluator として使い、transcript 内の trusted evidence が condition を満たすか確認します。不足なら gate が停止を拒み、次ラウンドへ「作業を続ける」prompt を queue します。十分なら goal を消して complete とします。
+
+
+
+s01 の loop と比べて、追加されるのは 1 つの判断だけです。モデルが止まりたいとき、先に goal gate を通ります。
+
+```python
+# s01: モデルが stop と言えば停止
+if not has_tool_use(response):
+ return
+# s22: 止まりたい?先に goal gate を通る
+if not has_tool_use(response):
+ verdict = goal.evaluate_after_turn()
+ if verdict == "continuing":
+ continue # 未達成 -> 次のラウンドへ押し戻す
+ return # 達成 / budget 超過 / goal なし -> 本当に停止
+```
+
+この gate を制御するのは program です。モデルが自分を律しているのではありません。モデルは gate の存在すら知らず、次のラウンドの入力を受け取って作業を続けるだけです。
+
+> 実際の Claude Code では `/goal` は session-scoped Stop hook で、workspace trust と hook restriction の管理下にあります。コードには `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` などの marker があります。
+
+## Goal の設定: Evidence は command の後から数える
+
+`set_goal` は active goal として、goal text、最大 turn budget、counter、そして evidence window の開始点 `start_index` を保存します。現在の transcript length を使うため、`/goal` command 自身は window の外です。これが最初の防御です。command が自分自身の完了を証明することはできません。
+
+```python
+def set_goal(self, objective, max_turns=20):
+ self.active = {
+ "objective": objective, "status": "active",
+ "start_index": len(self.transcript), # evidence はここから。command 自身は window 外
+ "max_turns": max_turns, "checks": 0, "continuation_turns": 0,
+ }
+```
+
+> 実際の Claude Code では `GoalRuntime.setGoal()` が active goal、開始位置、counter、budget を保存し、submit 後に `resetEvidenceStart()` で window を command 後へそろえます。
+
+## Evaluator: 実在する evidence だけを信頼する
+
+ここが仕組み全体の core です。evaluator は会話全体を見ず、evidence window 内で trusted source から来た message だけを見ます。3 層の filter が、「完了したと言ったから完了」という内容をすべて外へ止めます。
+
+```python
+TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
+
+def evidence_text(self):
+ out = []
+ for m in self.transcript[self.active["start_index"]:]:
+ if m.origin.get("kind") == "slash-command": # 1 slash command 自身は evidence ではない
+ continue
+ if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal command text は evidence ではない
+ continue
+ if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 trusted origin だけを信頼
+ continue
+ out.append(f"{m.role}: {m.content}")
+ return "\n".join(out)
+```
+
+効果は明確です。同じ `tests passed` でも、あなたが入力したものは数えず、background task notification が持ち帰ったものだけを数えます。モデルは「完了した」と自分で言うだけでは goal を complete にできません。これはコース全体に繰り返し現れた trust boundary の最後の登場です。s16 は protocol が理解ではなく field に依存すると言い、s19 は annotation が申告であり、申告は嘘をつけると言い、s22 は completion evidence を content ではなく origin で信頼します。
+
+教材版の `goal_satisfied()` は決定的な keyword matching です。実際の版は evidence window を別の lightweight model へ渡して判定します。
+
+> 実際の Claude Code の evaluator は作業モデルとは別の lightweight model で、`evaluatorModel`、`default small fast model` と記されています。任意の text を信じず、会話内の evidence を判断します。
+
+## Gate の 3 状態: Completed / continuing / budget 超過
+
+`evaluate_after_turn` は各 turn で 1 回動き、3 つの結果を返します。condition が満たされれば goal を completed として消します。満たされず budget が残れば「作業を続ける」prompt を queue し、continuing として次ラウンドを許可します。budget を使い切れば blocked で gate を解除し、永遠に判定できない goal が無限に費用を使わないようにします。
+
+```python
+def evaluate_after_turn(self):
+ g = self.active
+ g["checks"] += 1
+ if self.goal_satisfied():
+ g["status"] = "completed"; self.active = None
+ return "completed" # 達成 -> goal を消す
+ if g["continuation_turns"] < g["max_turns"]:
+ g["continuation_turns"] += 1
+ self.queue.enqueue(
+ value="作業を続けてください。この reminder を completion evidence として扱わないでください。",
+ origin={"kind": "active-goal"})
+ return "continuing" # 未達成 -> prompt を queue し、次ラウンドへ
+ g["status"] = "blocked"; self.active = None
+ return "blocked" # budget 超過 -> gate を解除
+```
+
+continuation prompt には、わざわざ自身を evidence にしないよう書き、filter でも除外します。これで false positive を防ぐ 3 層がそろいます。command text、reminder text、ordinary conversation のいずれも数えません。budget は s11 の古い規則に従います。automatic retry mechanism には必ず上限が必要です。そうでなければ、永遠に satisfied にならない goal が費用を燃やし続けます。
+
+> 実際の Claude Code の `evaluateAfterTurn` は `goal_evaluated` event を出し、結果に応じて complete、continuation queue、gate の解除を行います。default budget は 20 turn です。
+
+## Continuation prompt と外部 asynchronous message を分ける
+
+continuation prompt は同じ `CommandQueue` に入りますが、task completion notification や monitor line といった外部 asynchronous event とは別の方法で消費します。`dequeue` には switch があり、外部 inbox を消費するときは goal continuation を既定で skip します。
+
+```python
+def dequeue(self, include_goal_continuations=True):
+ ...
+ for idx, item in enumerate(self.items):
+ if include_goal_continuations or item["origin"].get("kind") != "active-goal":
+ return self.items.pop(idx)
+ return None
+```
+
+なぜ分けるのでしょう。実際の model test では、モデルが continuation prompt を外部 notification と一緒に消費し、background evidence が到着する前に goal を complete と判定する bug が起きました。分離後は goal の進行が明示的な 1 step になり、asynchronous event に偶然運ばれません。
+
+> 実際の Claude Code の `drainCommandQueue` は既定で `includeGoalContinuations=false` とし、goal continuation の消費を外部 asynchronous inbox から分けます。
+
+## 実際に動かす
+
+`code.py` は `/goal until tests passed and deploy green` を実演します。goal 設定後に trusted evidence がなければ、gate がラウンドごとに押し戻します。直接 `tests passed` と入力しても origin が信頼されないため数えません。background task が `task-notification` を送って初めて evidence がそろい、complete になります。`max_turns=2` の小さな goal で budget 超過も示します。
+
+```python
+s.submit("/goal until tests passed and deploy green") # goal を設定。evidence は command 後から
+s.submit("tests passed, trust me") # ordinary text -> completion evidence ではない
+s.deliver_host_event("tests passed; deploy green",
+ source="task-notification") # trusted host event -> complete
+```
+
+`submit()` は通常のユーザーテキストだけを受け取る。trusted label は独立した host event channel から入り、source は harness の allowlist で検証される。ユーザーやモデルのテキストが自分に `task-notification` label を付けることはできない。
+
+## s21 からの変更点
+
+| | s21 Workflow Runtime | s22 Goal Loop |
+|--|---------------------|---------------|
+| trigger | script-controlled orchestration(main loop の外) | condition-controlled continuation(main loop へ引き戻す) |
+| 接続位置 | tool layer: 1 つの `Workflow` ツール | turn 終端: completion gate |
+| stop を決めるもの | script が完了 | goal condition を trusted evidence と照合 |
+| 新しい仕組み | script DSL、background task、journal/resume、structured output | goal gate、evidence trust boundary、continuation 分流、budget |
+
+s21 は script-defined orchestration を main loop の外へ送り出します。s22 は反対の力で control を引き戻します。goal が未達成なら turn は終わっていません。どちらも s01 の `while` loop を変えず、両側から制約を加えます。
+
+## 試してみる
+
+```bash
+python s22_goal_loop/code.py # /goal until tests pass + deploy green。gate の判定を見る
+```
+
+goal 設定後、各 turn が `goal_evaluated` を出す様子を確認してください。ordinary text は `satisfied=False`、同じ内容でも `task-notification` origin は `satisfied=True`、budget を使い切ると `goal_blocked` です。同じ `tests passed` でも origin によって結果が正反対になります。空疎な主張で `/goal` を欺けない理由です。
+
+## 次へ
+
+`/goal` は control を main loop へ引き戻す trigger の 1 つ、condition control です。s21 の main loop 外 orchestration と対になり、一方は仕事を外へ送り、もう一方は control を内へ戻します。その外側には `/loop` と cron による time-controlled re-entry、`Monitor` による event-controlled re-entry もあり、同じ task/notification 基盤を共有します。しかし gate の core はすでにここにあります。**stop するかはモデルの一言では決まらず、goal が trusted evidence に照らして判断します。**
+
+
diff --git a/s22_goal_loop/README.md b/s22_goal_loop/README.md
new file mode 100644
index 00000000..abd55e05
--- /dev/null
+++ b/s22_goal_loop/README.md
@@ -0,0 +1,164 @@
+# s22: Goal Loop — The Goal Decides When to Stop, Not the Model
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s20 → s21 → `s22`
+
+> *"A turn ends only when the goal condition is satisfied, not merely when the model says stop"* — `/goal` adds a gate at the end of every main-loop turn. An independent evaluator checks whether trusted evidence is sufficient; if not, it pushes the model into another round.
+>
+> **Harness layer**: Goal closure — a program-controlled completion gate at the end of each turn.
+
+> **Source boundary:** Product details in this chapter are a clean-room behavioral reconstruction of Claude Code 2.1.177. Names and limits may change in later releases; `code.py` is an offline teaching model, not copied product source.
+
+---
+
+From s01 through s21, how does a conversation turn end? When the model stops emitting `tool_use`, the loop simply executes `return`. That is fine for one-shot work: finish and stop.
+
+Some objectives, however, must be carried through to completion: "get the tests passing" or "do not stop until the deployment succeeds." Two problems appear often. The model does half the work, decides it is close enough, and stops. Worse, it says `tests passed` and tries to declare victory. The requirement is simple: the model cannot decide by itself whether the turn may end. An explicit condition must be evaluated against concrete evidence.
+
+This thread was present from the first chapter. s01 explained that exiting the loop is a model decision. s04's Stop hook gave the program veto power for the first time. This chapter turns that veto into a complete loop with three indispensable parts: condition, evidence, and budget.
+
+## /goal: Add a Gate at the End of Every Turn
+
+Entering `/goal ` sets a session-scoped stopping condition. The program stores it as the active goal. After each turn, an independent lightweight model acts as evaluator and checks whether trusted evidence in the transcript satisfies the condition. If evidence is insufficient, the gate blocks the attempted stop and queues a "keep working" prompt for the next round. If it is sufficient, the goal is cleared and marked complete.
+
+
+
+Compared with the s01 loop, there is only one additional decision: when the model wants to stop, it must first pass the goal gate.
+
+```python
+# s01: stop when the model says stop
+if not has_tool_use(response):
+ return
+# s22: want to stop? Pass the goal gate first
+if not has_tool_use(response):
+ verdict = goal.evaluate_after_turn()
+ if verdict == "continuing":
+ continue # Not achieved -> push back for another round
+ return # Achieved / over budget / no goal -> really stop
+```
+
+The program controls this gate. It is not the model restraining itself. The model does not even know the gate exists; it simply receives another round of input and continues working.
+
+> In the real Claude Code, `/goal` is a session-scoped Stop hook governed by workspace trust and hook restrictions. The code contains markers such as `active_goal`, `goal_status`, `goal_met`, and `tengu_goal_achieved`.
+
+## Setting a Goal: Evidence Starts after the Command
+
+`set_goal` stores an active goal containing the objective text, a maximum-turn budget, counters, and `start_index`, the beginning of the evidence window. It uses the transcript's current length, placing the `/goal` command itself outside the window. This is the first defense: a command cannot prove its own completion.
+
+```python
+def set_goal(self, objective, max_turns=20):
+ self.active = {
+ "objective": objective, "status": "active",
+ "start_index": len(self.transcript), # Evidence starts here; the command is outside the window
+ "max_turns": max_turns, "checks": 0, "continuation_turns": 0,
+ }
+```
+
+> In the real Claude Code, `GoalRuntime.setGoal()` stores the active goal, start position, counters, and budget, then `resetEvidenceStart()` aligns the window to the position after command submission.
+
+## The Evaluator: Trust Concrete Evidence Only
+
+This is the core of the entire mechanism. The evaluator does not inspect the whole conversation. It sees only messages inside the evidence window that come from trusted sources. Three filters keep every form of "I said it was done, so it must be done" outside:
+
+```python
+TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
+
+def evidence_text(self):
+ out = []
+ for m in self.transcript[self.active["start_index"]:]:
+ if m.origin.get("kind") == "slash-command": # 1 Slash commands are not evidence
+ continue
+ if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal command text is not evidence
+ continue
+ if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 Trust only approved origins
+ continue
+ out.append(f"{m.role}: {m.content}")
+ return "\n".join(out)
+```
+
+The effect is clear. The same sentence, `tests passed`, does not count when typed by you, but does count when delivered by a background task notification. The model cannot bluff its way out by saying "I finished." This is the final appearance of the trust boundary repeated throughout the course. s16 said protocols rely on fields, not interpretation. s19 said annotations are claims and claims may be false. s22 says completion evidence is trusted by origin, not by content alone.
+
+The teaching version's `goal_satisfied()` uses deterministic keyword matching. The real version asks a separate lightweight model to judge the evidence window.
+
+> In the real Claude Code, the evaluator is a lightweight model separate from the working model, marked as `evaluatorModel` and the `default small fast model`. It judges evidence in the conversation rather than trusting arbitrary text.
+
+## Three Gate States: Completed, Continuing, or Over Budget
+
+`evaluate_after_turn` runs after every turn and returns one of three results. If the condition is satisfied, it clears the goal as completed. If the condition is not satisfied and budget remains, it queues a "keep working" prompt and permits another round as continuing. If the budget is exhausted, it stops blocking and marks the goal blocked, preventing an impossible goal from burning money forever.
+
+```python
+def evaluate_after_turn(self):
+ g = self.active
+ g["checks"] += 1
+ if self.goal_satisfied():
+ g["status"] = "completed"; self.active = None
+ return "completed" # Achieved -> clear the goal
+ if g["continuation_turns"] < g["max_turns"]:
+ g["continuation_turns"] += 1
+ self.queue.enqueue(
+ value="Keep working. Do not treat this reminder as completion evidence.",
+ origin={"kind": "active-goal"})
+ return "continuing" # Not achieved -> queue a prompt for the next round
+ g["status"] = "blocked"; self.active = None
+ return "blocked" # Over budget -> release the gate
+```
+
+The continuation prompt explicitly says not to treat itself as evidence, and the evidence filter excludes it. That completes the three layers against false positives: the command does not count, the reminder does not count, and ordinary conversation does not count. The budget follows the old rule from s11: every automatic retry mechanism needs a limit. Otherwise, a goal that can never be satisfied becomes a perpetual money-burning machine.
+
+> In the real Claude Code, `evaluateAfterTurn` emits a `goal_evaluated` event and either completes, queues a continuation, or stops blocking. The default budget is 20 turns.
+
+## Keep Continuation Prompts Separate from External Asynchronous Messages
+
+Continuation prompts enter the same `CommandQueue`, but they are not consumed in the same way as external asynchronous events such as task-completion notifications and monitor lines. `dequeue` has a switch, and consumption of the external inbox skips goal continuations by default.
+
+```python
+def dequeue(self, include_goal_continuations=True):
+ ...
+ for idx, item in enumerate(self.items):
+ if include_goal_continuations or item["origin"].get("kind") != "active-goal":
+ return self.items.pop(idx)
+ return None
+```
+
+Why separate them? A real model test exposed a bug where the model consumed the continuation prompt together with an external notification and marked the goal complete before background evidence arrived. With the paths separated, goal progression is an explicit step and cannot be carried along accidentally by asynchronous events.
+
+> In the real Claude Code, `drainCommandQueue` defaults to `includeGoalContinuations=false`, separating goal-continuation consumption from the external asynchronous inbox.
+
+## See It Run
+
+`code.py` demonstrates `/goal until tests passed and deploy green`. With no trusted evidence after goal creation, the gate pushes it back round after round. Typing `tests passed` directly still does not count because the origin is untrusted. Only after a background task sends a `task-notification` does the evidence satisfy the goal. A second small goal with `max_turns=2` demonstrates the over-budget path.
+
+```python
+s.submit("/goal until tests passed and deploy green") # Set the goal; evidence begins after this command
+s.submit("tests passed, trust me") # Ordinary text -> not completion evidence
+s.deliver_host_event("tests passed; deploy green",
+ source="task-notification") # Trusted host event -> complete
+```
+
+`submit()` accepts only ordinary user text. Trusted labels enter through the separate host-event channel, whose source is allowlisted by the harness; user or model text cannot attach its own `task-notification` label.
+
+## Changes from s21
+
+| | s21 Workflow Runtime | s22 Goal Loop |
+|--|---------------------|---------------|
+| Trigger | Script-controlled orchestration outside the main loop | Condition-controlled continuation pulled back into the main loop |
+| Attachment point | Tool layer: one `Workflow` tool | End of turn: a completion gate |
+| Who decides when to stop | The script finishes | Goal condition evaluated against trusted evidence |
+| New mechanisms | Script DSL, background tasks, journal/resume, structured output | Goal gate, evidence trust boundary, separate continuation path, budget |
+
+s21 sends script-defined orchestration away from the main loop. s22 applies an opposite force that pulls control back: if the goal is not achieved, the turn is not finished. Neither changes the `while` loop from s01; each constrains it from a different side.
+
+## Try It
+
+```bash
+python s22_goal_loop/code.py # /goal until tests pass + deploy green; watch the gate decide
+```
+
+After setting a goal, watch every turn produce `goal_evaluated`. Ordinary text yields `satisfied=False`; the same content from a `task-notification` origin yields `satisfied=True`; exhausted budget produces `goal_blocked`. The same `tests passed` sentence has opposite results depending on its origin. That is why an empty claim cannot fool `/goal`.
+
+## Next
+
+`/goal` is one kind of trigger that pulls control back into the main loop: condition control. It pairs naturally with s21's orchestration outside the main loop, one dispatching work outward and the other pulling control inward. Beyond them are time-controlled re-entry through `/loop` and cron, and event-controlled re-entry through `Monitor`; all share the same task and notification foundation. But the essential gate is already here: **the model's words do not decide whether to stop. The goal must judge trusted evidence.**
+
+
diff --git a/s22_goal_loop/README.zh.md b/s22_goal_loop/README.zh.md
new file mode 100644
index 00000000..8fb665dc
--- /dev/null
+++ b/s22_goal_loop/README.zh.md
@@ -0,0 +1,164 @@
+# s22: Goal Loop — 什么时候停,目标说了算,不是模型说了算
+
+[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
+
+s01 → ... → s20 → s21 → `s22`
+
+> *"一轮能不能结束,看目标条件满不满足,不是模型说停就停"* — `/goal` 在主循环每轮收尾的地方加一道闸门:每轮结束后,一个独立的判断器看可信证据够不够,不够就把模型推回去再来一轮。
+>
+> **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。
+
+> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制。
+
+---
+
+从 s01 到 s21,一轮对话怎么结束?模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。
+
+但有些目标你得盯着它做到底:"把测试跑过"、"部署成功了再说"。这时候经常出两种问题:模型做了一半觉得差不多了,自己就停了;更过分的是,它嘴上说一句 `tests passed` 就想收工。你要的其实很简单:这一轮能不能结束,不能模型自己说了算,得有个明确的条件,对着实打实的证据来判断。
+
+这条线其实从第一课就埋着了。s01 说过,退出循环本来是模型的一个决定;s04 的 Stop hook 第一次给了程序否决权。这一课把那个否决权做成完整的闭环:条件、证据、预算,三样缺一不可。
+
+## /goal:每轮收尾加一道闸门
+
+输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,用一个独立的轻量小模型当判断器,看对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。
+
+
+
+和 s01 的循环比,只多了一道判断,模型想停的时候先过目标这关:
+
+```python
+# s01:模型说停就停
+if not has_tool_use(response):
+ return
+# s22:想停?先过目标闸门
+if not has_tool_use(response):
+ verdict = goal.evaluate_after_turn()
+ if verdict == "continuing":
+ continue # 没达成 -> 推回去再来一轮
+ return # 达成/超预算/没目标 -> 真停
+```
+
+这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。
+
+> 真实 Claude Code:`/goal` 是会话级的 Stop hook,受工作区信任和 hook 限制控制;代码里有 `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` 这些标记。
+
+## 设目标:证据从命令之后开始算
+
+`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器,还有 `start_index`——也就是证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。
+
+```python
+def set_goal(self, objective, max_turns=20):
+ self.active = {
+ "objective": objective, "status": "active",
+ "start_index": len(self.transcript), # 证据窗口从这里开始;命令本身在窗口外
+ "max_turns": max_turns, "checks": 0, "continuation_turns": 0,
+ }
+```
+
+> 真实 Claude Code:`GoalRuntime.setGoal()` 存活跃目标、起始位置、计数器和预算;提交后再 `resetEvidenceStart()` 把窗口对齐到命令之后。
+
+## 判断器:只信实打实的证据
+
+这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把"嘴上说完成了但不算数"的内容全挡在外面:
+
+```python
+TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
+
+def evidence_text(self):
+ out = []
+ for m in self.transcript[self.active["start_index"]:]:
+ if m.origin.get("kind") == "slash-command": # 1 斜杠命令本身不算
+ continue
+ if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal 命令文本不算
+ continue
+ if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只信可信来源
+ continue
+ out.append(f"{m.role}: {m.content}")
+ return "\n".join(out)
+```
+
+效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句"我做完了"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场:s16 说协议靠字段不靠理解,s19 说注解是申报、申报可以撒谎,s22 说完成证据只看来源不看内容。
+
+教学版里 `goal_satisfied()` 是确定的关键词匹配;真实版会把证据窗口交给一个轻量小模型来判断。
+
+> 真实 Claude Code:判断器是和干活的模型分开的轻量小模型(标记是 `evaluatorModel`、`default small fast model`),判断对话里的证据,不是随便什么文本都信。
+
+## 闸门三态:完成/继续/超预算
+
+`evaluate_after_turn` 每轮跑一次,三种结果:满足条件就清除目标(completed);没满足而且预算还没花完,就往队列塞一条"继续干"的提示,放行下一轮(continuing);预算花完就停(blocked),别让一个永远判不出来的目标无限烧钱。
+
+```python
+def evaluate_after_turn(self):
+ g = self.active
+ g["checks"] += 1
+ if self.goal_satisfied():
+ g["status"] = "completed"; self.active = None
+ return "completed" # 达成 -> 清除目标
+ if g["continuation_turns"] < g["max_turns"]:
+ g["continuation_turns"] += 1
+ self.queue.enqueue(
+ value="继续干活,别把这条提醒当成完成证据。",
+ origin={"kind": "active-goal"})
+ return "continuing" # 没达成 -> 塞提示,下一轮
+ g["status"] = "blocked"; self.active = None
+ return "blocked" # 超预算 -> 放行,不再拦
+```
+
+那条"继续干"的提示里特意写了"别把这条提醒当成完成证据",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。
+
+> 真实 Claude Code:`evaluateAfterTurn` 会发 `goal_evaluated` 事件,按结果完成/塞继续提示/拦截;默认预算是 20 轮。
+
+## 继续提示和外部异步消息分开走
+
+继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。
+
+```python
+def dequeue(self, include_goal_continuations=True):
+ ...
+ for idx, item in enumerate(self.items):
+ if include_goal_continuations or item["origin"].get("kind") != "active-goal":
+ return self.items.pop(idx)
+ return None
+```
+
+为什么要分开?真实模型测试的时候出过一个 bug:模型把继续提示当成外部通知一起消费了,结果后台证据还没到,就提前把目标判成完成了。分开之后,目标的推进是显式的一步,不会被异步事件带着走。
+
+> 真实 Claude Code:`drainCommandQueue` 默认 `includeGoalContinuations=false`,把目标继续提示和外部异步收件箱的消费分开。
+
+## 跑起来看看
+
+`code.py` 演示了一个 `/goal until tests passed and deploy green`:设了目标之后没有可信证据,闸门一轮轮把它推回去;你直接打 `tests passed` 也不算(来源不可信);直到后台任务发来 `task-notification`,证据到位,才标记完成。还加了一个 `max_turns=2` 的小目标演示超预算拦截。
+
+```python
+s.submit("/goal until tests passed and deploy green") # 设目标,窗口在命令之后
+s.submit("tests passed, trust me") # 普通文本 -> 不算完成
+s.deliver_host_event("tests passed; deploy green",
+ source="task-notification") # 可信宿主事件 -> 完成
+```
+
+`submit()` 只接受普通用户文本。可信标签必须走独立的宿主事件通道,来源由 harness 白名单校验;用户或模型文本不能给自己贴上 `task-notification` 标签。
+
+## 相对 s21 的变更
+
+| | s21 Workflow Runtime | s22 Goal Loop |
+|--|---------------------|---------------|
+| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(拉回主循环) |
+| 加在哪 | 工具层:一个 `Workflow` 工具 | 轮次收尾:一道完成闸门 |
+| 谁决定停 | 脚本跑完就停 | 目标条件对着可信证据判 |
+| 新增机制 | 脚本 DSL、后台任务、journal/续跑、结构化输出 | 目标闸门、证据信任边界、继续提示分流、预算 |
+
+s21 是把编排写成脚本、派出去脱离主循环;s22 反过来,是一股力量把控制权重拉回主循环:目标没达成,这一轮就不算结束。两个都不改 s01 那个 `while` 循环,只是从两头给它加约束。
+
+## 试一下
+
+```bash
+python s22_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判
+```
+
+观察:设了目标之后,每轮结束都有一条 `goal_evaluated`;普通文本判 `satisfied=False`,`task-notification` 来源判 `satisfied=True`;预算花完的时候出 `goal_blocked`。同样一句 `tests passed`,来源不同,结果完全相反。这就是 `/goal` 不会被一句空话糊弄的地方。
+
+## 接下来
+
+`/goal` 是"拉回主循环"的一种触发:条件控制。它和 s21 的"脱离主循环"正好成对,一个把工作派出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**
+
+
diff --git a/s22_goal_loop/code.py b/s22_goal_loop/code.py
new file mode 100644
index 00000000..f0eadfe0
--- /dev/null
+++ b/s22_goal_loop/code.py
@@ -0,0 +1,291 @@
+"""
+s22_goal_loop — /goal session goal loop (teaching version)
+
+Clean-room behavioral reconstruction of Claude Code's `/goal` command. Grounded
+in @anthropic-ai/claude-code@2.1.177 observed behavior
+(reverse-research/cc_goal_loop), NOT leaked source.
+
+Idea:
+ s01-s21 end a turn when the model emits no tool_use. `/goal` adds a
+ host-owned turn-completion GATE: the user sets a stopping CONDITION, and after
+ every turn a separate evaluator judges whether trusted transcript evidence
+ satisfies it. Not satisfied -> the gate blocks the stop and feeds a
+ continuation into the next turn. Satisfied -> the active goal is cleared.
+
+ So the core contrast with s01 is one extra check before "return":
+
+ # s01: the model says stop -> stop
+ if not has_tool_use(response):
+ return
+ # s22: when it wants to stop, pass the goal gate first
+ if not has_tool_use(response):
+ verdict = goal.evaluate_after_turn()
+ if verdict == "continuing":
+ continue # not met -> push it back
+ return # met / over budget / no goal -> really stop
+
+Run:
+ python code.py # /goal until tests pass + deploy green; watch the gate
+
+Teaching simplifications (vs real /goal and runtime.mjs):
+ - The evaluator is a deterministic keyword check, not a small/fast model.
+ - One mock task-notification produces the trusted evidence; the loop / monitor
+ / background-task plane (s13/s14) is out of scope — this chapter is just the
+ goal gate.
+ - The evidence trust boundary is the faithful part: only task-notification /
+ monitor-line origins count as evidence, so the `/goal` command text, the
+ continuation reminder, and plain assistant prose can NOT satisfy the goal.
+ Ordinary `submit()` calls cannot set those labels; only the host-event
+ ingress can deliver an allowlisted source.
+"""
+
+import itertools
+import sys
+
+# ---- ids + a one-line event stream so the gate is visible ----
+_ids = itertools.count(1)
+
+
+def make_id(prefix):
+ return f"{prefix}-{next(_ids):03d}"
+
+
+def event(lane, etype, detail=""):
+ print(f" · {lane:<6} {etype:<26} {detail}")
+
+
+# A message's origin.kind is the TRUST LABEL that decides whether it can count
+# as goal evidence. Trusted async origins land real tool/task evidence; user /
+# slash-command / active-goal (the continuation reminder) / assistant do not.
+TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
+
+
+class Message:
+ def __init__(self, role, content, origin):
+ self.role = role
+ self.content = content
+ self.origin = origin or {"kind": "user"}
+
+
+# ============================================================
+# CommandQueue — continuation prompts live here (mirrors CommandQueue)
+# ============================================================
+class CommandQueue:
+ PRIORITY = {"now": 0, "next": 1, "later": 2}
+
+ def __init__(self):
+ self.items = []
+
+ def enqueue(self, value, priority="next", origin=None):
+ item = {"id": make_id("cmd"), "priority": priority,
+ "origin": origin or {}, "value": value}
+ self.items.append(item)
+ return item
+
+ def dequeue(self, include_goal_continuations=True):
+ # Goal continuations and the external async inbox are NOT the same drain.
+ # With include_goal_continuations=False an inbox drain skips them, so a
+ # goal can't be advanced (or blocked) before real evidence arrives.
+ self.items.sort(key=lambda i: self.PRIORITY.get(i["priority"], 1))
+ for idx, item in enumerate(self.items):
+ if include_goal_continuations or item["origin"].get("kind") != "active-goal":
+ return self.items.pop(idx)
+ return None
+
+ def remove_by_origin(self, kind):
+ before = len(self.items)
+ self.items = [i for i in self.items if i["origin"].get("kind") != kind]
+ return before - len(self.items)
+
+ def __len__(self):
+ return len(self.items)
+
+
+# ============================================================
+# GoalRuntime — the turn-completion gate (mirrors GoalRuntime)
+# ============================================================
+class GoalRuntime:
+ def __init__(self, transcript, queue):
+ self.transcript = transcript # shared session transcript
+ self.queue = queue
+ self.active = None
+
+ def set_goal(self, objective, max_turns=20):
+ # start_index marks the evidence window. The /goal command line is
+ # already recorded, so it sits OUTSIDE the window and can't satisfy
+ # itself.
+ self.active = {
+ "id": make_id("goal"), "objective": objective, "status": "active",
+ "start_index": len(self.transcript), "max_turns": max_turns,
+ "checks": 0, "continuation_turns": 0,
+ }
+ event("goal", "goal_started", f"{self.active['id']} :: {objective}")
+ return self.active
+
+ def clear(self, reason="cleared"):
+ if not self.active:
+ return
+ self.active["status"] = reason
+ self.queue.remove_by_origin("active-goal")
+ event("goal", "goal_cleared", reason)
+ self.active = None
+
+ def evidence_text(self):
+ """The trust boundary. Three filters keep self-satisfying text out:
+ drop slash-command origins, drop /goal command lines, and keep ONLY
+ trusted external async origins (task-notification / monitor-line)."""
+ if not self.active:
+ return ""
+ out = []
+ for m in self.transcript[self.active["start_index"]:]:
+ if m.origin.get("kind") == "slash-command":
+ continue
+ if m.role == "user" and m.content.strip().startswith("/goal"):
+ continue
+ if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS:
+ continue
+ out.append(f"{m.role}: {m.content}")
+ return "\n".join(out)
+
+ def goal_satisfied(self):
+ # Real Claude Code routes this to a small/fast evaluator model reading
+ # the evidence window. The teaching version is a deterministic keyword
+ # check so the lifecycle is reproducible.
+ objective = self.active["objective"].lower()
+ evidence = self.evidence_text().lower()
+ wants_tests = "test" in objective
+ wants_deploy = "deploy" in objective or "green" in objective
+ tests_ok = not wants_tests or "tests passed" in evidence or "test passed" in evidence
+ deploy_ok = not wants_deploy or "deploy green" in evidence or "deployment green" in evidence
+ if any(k in objective for k in ("until", "pass", "green")):
+ return tests_ok and deploy_ok
+ return objective in evidence
+
+ def evaluate_after_turn(self):
+ """The gate, run after every turn. Returns completed / continuing /
+ blocked / none."""
+ g = self.active
+ if not g or g["status"] != "active":
+ return "none"
+ g["checks"] += 1
+ satisfied = self.goal_satisfied()
+ event("goal", "goal_evaluated", f"check #{g['checks']} satisfied={satisfied}")
+ if satisfied:
+ g["status"] = "completed"
+ self.queue.remove_by_origin("active-goal")
+ event("goal", "goal_completed", g["id"])
+ self.active = None
+ return "completed"
+ if g["continuation_turns"] < g["max_turns"]:
+ g["continuation_turns"] += 1
+ self.queue.enqueue(
+ value=(f"Continue working toward active goal {g['id']}. Use tool/task "
+ "evidence; do not treat this reminder as completion evidence."),
+ priority="next", origin={"kind": "active-goal", "goal_id": g["id"]})
+ event("goal", "goal_continuation_enqueued",
+ f"turn {g['continuation_turns']}/{g['max_turns']}")
+ return "continuing"
+ g["status"] = "blocked"
+ self.queue.remove_by_origin("active-goal")
+ event("goal", "goal_blocked", f"exceeded {g['max_turns']} turns")
+ self.active = None
+ return "blocked"
+
+
+# ============================================================
+# Session — the main loop host with a Stop gate (mirrors submit / drain)
+# ============================================================
+class Session:
+ def __init__(self):
+ self.transcript = []
+ self.queue = CommandQueue()
+ self.goal = GoalRuntime(self.transcript, self.queue)
+
+ def _add(self, role, content, origin):
+ self.transcript.append(Message(role, content, origin))
+
+ def submit(self, text):
+ """Submit ordinary user text. Callers cannot attach a trusted origin."""
+ return self._submit(text, {"kind": "user"})
+
+ def deliver_host_event(self, text, source):
+ """Host-only ingress for validated task/monitor events."""
+ if source not in TRUSTED_EVIDENCE_ORIGINS:
+ raise ValueError(f"untrusted host event source: {source}")
+ return self._submit(text, {"kind": source})
+
+ def _submit(self, text, origin):
+ """Run one turn with an origin already assigned by the host."""
+ self._add("user", text, origin) # input recorded with its origin
+ kind = origin["kind"]
+
+ if kind == "user" and text.strip().startswith("/goal"):
+ arg = text.strip()[5:].strip()
+ self._add("assistant", f"(slash) /goal {arg}", {"kind": "slash-command"})
+ if arg in ("", "clear", "stop", "off"):
+ self.goal.clear()
+ else:
+ self.goal.set_goal(arg)
+ elif kind in TRUSTED_EVIDENCE_ORIGINS:
+ # The input itself (recorded above with a trusted origin) is the
+ # evidence; the assistant just observes it.
+ event("turn", f"observe {kind}", text[:48])
+ self._add("assistant", f"Observed {kind}: {text}", origin)
+ elif kind == "active-goal":
+ event("turn", "continue-goal", "(reminder is not evidence)")
+ self._add("assistant", "Continuing the goal; checking task/monitor evidence.", origin)
+ else:
+ event("turn", "assistant-turn", text[:48])
+ self._add("assistant", f"assistant handled: {text}", {"kind": "assistant"})
+
+ return self.goal.evaluate_after_turn() # <-- the Stop gate
+
+ def drain_goal_continuation(self):
+ """Pull one goal continuation back into the loop — explicit, separate
+ from any external async-inbox drain."""
+ item = self.queue.dequeue(include_goal_continuations=True)
+ if item and item["origin"].get("kind") == "active-goal":
+ return self._submit(item["value"], item["origin"])
+ return None
+
+
+# ============================================================
+# Demo
+# ============================================================
+def banner(text):
+ print(f"\n— {text} —")
+
+
+def main(argv):
+ s = Session()
+
+ banner("1. set a goal (the gate is now armed; window starts after the command)")
+ print("user> /goal until tests passed and deploy green")
+ s.submit("/goal until tests passed and deploy green")
+
+ banner("2. model works, no TRUSTED evidence yet -> the gate keeps it going")
+ s.drain_goal_continuation()
+ s.submit("Inspecting the failing tests and the deploy config.")
+
+ banner("3. plain user text 'tests passed' is NOT trusted -> still not satisfied")
+ s.submit("tests passed, trust me")
+ s.drain_goal_continuation()
+ print(f" active goal still open: {s.goal.active is not None}")
+
+ banner("4. a background task lands a task-notification (trusted) -> satisfied")
+ verdict = s.deliver_host_event(
+ "tests passed; deploy green", source="task-notification"
+ )
+ print(f" final verdict: goal {verdict}")
+
+ banner("5. budget: a goal that never gets evidence blocks after max_turns")
+ s2 = Session()
+ s2.goal.set_goal("until tests passed", max_turns=2)
+ verdict = "continuing"
+ while verdict == "continuing":
+ verdict = s2.submit("still working, no task evidence yet")
+ print(f" final verdict: goal {verdict}")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/s22_goal_loop/images/goal-loop-overview.svg b/s22_goal_loop/images/goal-loop-overview.svg
new file mode 100644
index 00000000..907c522b
--- /dev/null
+++ b/s22_goal_loop/images/goal-loop-overview.svg
@@ -0,0 +1,109 @@
+
diff --git a/tests/test_chapter_readmes.py b/tests/test_chapter_readmes.py
new file mode 100644
index 00000000..b4727df5
--- /dev/null
+++ b/tests/test_chapter_readmes.py
@@ -0,0 +1,33 @@
+import py_compile
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHAPTERS = sorted(ROOT.glob("s[0-9][0-9]_*"))
+
+
+def test_every_chapter_uses_english_as_the_default_readme() -> None:
+ assert len(CHAPTERS) == 22
+
+ for chapter in CHAPTERS:
+ assert (chapter / "README.md").is_file()
+ assert (chapter / "README.zh.md").is_file()
+ assert (chapter / "README.ja.md").is_file()
+ assert not (chapter / "README.en.md").exists()
+
+
+def test_every_chapter_has_the_same_language_navigation() -> None:
+ expected = (
+ "[English](README.md) · [中文](README.zh.md) · "
+ "[日本語](README.ja.md)"
+ )
+
+ for chapter in CHAPTERS:
+ for filename in ("README.md", "README.zh.md", "README.ja.md"):
+ lines = (chapter / filename).read_text().splitlines()
+ assert lines[2] == expected
+
+
+def test_every_chapter_script_compiles_on_python_311() -> None:
+ for chapter in CHAPTERS:
+ _ = py_compile.compile(str(chapter / "code.py"), doraise=True)
diff --git a/tests/test_workflow_goal_lessons.py b/tests/test_workflow_goal_lessons.py
new file mode 100644
index 00000000..18bb3297
--- /dev/null
+++ b/tests/test_workflow_goal_lessons.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def load_lesson(name: str, script: Path):
+ spec = importlib.util.spec_from_file_location(name, script)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"unable to load {script}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def run_lesson(script: Path, *args: str) -> str:
+ result = subprocess.run(
+ [sys.executable, str(script), *args],
+ cwd=script.parent,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ return result.stdout
+
+
+def test_workflow_runtime_resumes_from_journal(tmp_path: Path) -> None:
+ script = tmp_path / "code.py"
+ shutil.copy2(ROOT / "s21_workflow_runtime" / "code.py", script)
+
+ first = run_lesson(script)
+ resumed = run_lesson(script, "resume")
+
+ assert "status=completed" in first
+ assert "async_launched" in first
+ assert "status=cached" in resumed
+ assert "status=completed agents=0 tokens=0" in resumed
+
+
+def test_workflow_runtime_rejects_unsafe_artifact_names() -> None:
+ workflow = load_lesson(
+ "workflow_name_test", ROOT / "s21_workflow_runtime" / "code.py"
+ )
+
+ for name in ("../escape", "../../escape", "nested/name"):
+ with pytest.raises(workflow.WorkflowInputError):
+ workflow.validate_meta({"name": name, "description": "unsafe"})
+
+
+def test_workflow_runtime_enforces_budget_and_shared_agent_cap(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ workflow = load_lesson(
+ "workflow_limit_test", ROOT / "s21_workflow_runtime" / "code.py"
+ )
+ budget = workflow.Budget(total=1)
+ with pytest.raises(workflow.WorkflowInputError):
+ budget.add(2)
+ assert budget.spent() == 0
+
+ journal = workflow.WorkflowJournal(
+ "wf_limit-test_0001", resume=False, store=tmp_path
+ )
+ task = workflow.LocalWorkflowTask("task", "wf_limit-test_0001", {})
+ state = workflow.ExecutionState(
+ task, journal, workflow.MockAgentRunner(), workflow.Budget(), {}
+ )
+
+ async def child(child_state, _args):
+ return await child_state.agent("second call")
+
+ monkeypatch.setattr(workflow, "AGENT_CAP", 1)
+ monkeypatch.setitem(
+ workflow.WORKFLOWS,
+ "limit-child",
+ ({"name": "limit-child", "description": "test"}, child),
+ )
+
+ async def run() -> None:
+ await state.agent("first call")
+ with pytest.raises(workflow.WorkflowInputError):
+ await state.workflow("limit-child")
+
+ async def fail_stage(_value, _item, _index):
+ raise RuntimeError("stage failed")
+
+ with pytest.raises(RuntimeError, match="stage failed"):
+ await state.pipeline(["item"], fail_stage)
+
+ try:
+ asyncio.run(run())
+ finally:
+ journal.close()
+
+
+def test_workflow_runtime_rejects_corrupt_resume_journal(tmp_path: Path) -> None:
+ workflow = load_lesson(
+ "workflow_journal_test", ROOT / "s21_workflow_runtime" / "code.py"
+ )
+ run_id = "wf_corrupt_0001"
+ (tmp_path / f"{run_id}.journal.jsonl").write_text("{not-json}\n")
+
+ with pytest.raises(workflow.WorkflowInputError, match="line 1"):
+ workflow.WorkflowJournal(run_id, resume=True, store=tmp_path)
+
+
+def test_goal_loop_requires_trusted_evidence_and_has_a_budget(
+ tmp_path: Path,
+) -> None:
+ script = tmp_path / "code.py"
+ shutil.copy2(ROOT / "s22_goal_loop" / "code.py", script)
+
+ output = run_lesson(script)
+
+ assert "active goal still open: True" in output
+ assert "final verdict: goal completed" in output
+ assert "final verdict: goal blocked" in output
+
+
+def test_goal_loop_separates_user_input_from_host_evidence() -> None:
+ goal_loop = load_lesson("goal_trust_test", ROOT / "s22_goal_loop" / "code.py")
+ session = goal_loop.Session()
+
+ assert session.submit("/goal until tests passed") == "continuing"
+ assert session.submit("tests passed") == "continuing"
+ assert session.goal.active is not None
+
+ with pytest.raises(ValueError):
+ session.deliver_host_event("tests passed", source="user")
+
+ assert (
+ session.deliver_host_event("tests passed", source="task-notification")
+ == "completed"
+ )
diff --git a/web/public/course-assets/s09_memory/memory-subsystems.en.svg b/web/public/course-assets/s09_memory/memory-subsystems.en.svg
index 914f1b0f..3dbc3db7 100644
--- a/web/public/course-assets/s09_memory/memory-subsystems.en.svg
+++ b/web/public/course-assets/s09_memory/memory-subsystems.en.svg
@@ -53,7 +53,7 @@
.memory/ — MEMORY.md index + *.md files (YAML frontmatter: name / description / type)
-
+ read/write
diff --git a/web/public/course-assets/s09_memory/memory-subsystems.ja.svg b/web/public/course-assets/s09_memory/memory-subsystems.ja.svg
index 6bbd6814..21bc3758 100644
--- a/web/public/course-assets/s09_memory/memory-subsystems.ja.svg
+++ b/web/public/course-assets/s09_memory/memory-subsystems.ja.svg
@@ -53,7 +53,7 @@
.memory/ — MEMORY.md インデックス + *.md ファイル(YAML frontmatter: name / description / type)
-
+ 読み/書き
diff --git a/web/public/course-assets/s09_memory/memory-subsystems.svg b/web/public/course-assets/s09_memory/memory-subsystems.svg
index f7673169..069cb0f3 100644
--- a/web/public/course-assets/s09_memory/memory-subsystems.svg
+++ b/web/public/course-assets/s09_memory/memory-subsystems.svg
@@ -53,7 +53,7 @@
.memory/ — MEMORY.md 索引 + *.md 文件(YAML frontmatter: name / description / type)
-
+ 写入/读取
diff --git a/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg b/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg
index 7dd6b28c..36e335ba 100644
--- a/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg
+++ b/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg
@@ -1,4 +1,4 @@
-