diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml new file mode 100644 index 00000000..4dbc0f46 --- /dev/null +++ b/.github/workflows/book.yml @@ -0,0 +1,105 @@ +name: Book CI + +on: + push: + branches: [main] + paths: + - '00-前言.md' + - '第一部分-基础篇/**' + - '第二部分-核心系统篇/**' + - '第三部分-高级模式篇/**' + - '第四部分-工程实践篇/**' + - '附录/**' + - 'en/**' + - 'yuyu-book/**' + - 'scripts/**' + - 'tests/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/book.yml' + pull_request: + paths: + - '00-前言.md' + - '第一部分-基础篇/**' + - '第二部分-核心系统篇/**' + - '第三部分-高级模式篇/**' + - '第四部分-工程实践篇/**' + - '附录/**' + - 'en/**' + - 'yuyu-book/**' + - 'scripts/**' + - 'tests/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/book.yml' + +permissions: + contents: read + +jobs: + book: + runs-on: ubuntu-latest + # A healthy run finishes in under 4 minutes, so 30 was never about the + # build — it was the ceiling that killed a run whose apt mirror crawled. + # Kept generous enough that a slow mirror still ships a book. + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: npm ci + + - run: python -m pip install -r yuyu-book/requirements.txt + + # Playwright ships as a Python dependency here, so the browser comes from + # `python -m playwright install`, not `npx playwright`. The build is run + # without --browser on purpose: only the auto-detected msedge/chrome/ + # chromium launches pass --allow-file-access-from-files, which the mermaid + # harness needs to import the ESM bundle from file://. Noto CJK faces are + # required for the printed pages and the diagram labels. + # + # Two downloads, two steps, each with its own clock. One run drew an + # azure.archive.ubuntu.com serving fonts-noto-cjk at 25 kB/s: it spent 26 + # minutes on the font package, never reached the browser, and the job died + # as "cancelled" with the culprit buried in the log. Bounding each fetch + # says which mirror failed, on the step that failed, in minutes. + - name: Install CJK fonts + timeout-minutes: 12 + run: | + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 \ + install -y fonts-noto-cjk + + - name: Install Chromium + timeout-minutes: 15 + run: python -m playwright install --with-deps chromium + + - run: python scripts/check_book.py + + - run: npm run check:diagrams + + - name: Unit tests + run: | + python -m unittest discover -s tests -v + python -m unittest discover -s yuyu-book/tests -v + + # Also warms yuyu-book/.cache/mermaid so the PDF pass reuses the plates. + - name: Assemble and audit both editions + run: python yuyu-book/build.py --check + + - name: Build both PDF editions + run: python yuyu-book/build.py --lang both --pdf + + - uses: actions/upload-artifact@v4 + with: + name: yuyu-book-dist + path: yuyu-book/dist/* + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 7cf0d320..21af8d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ node_modules/ __pycache__/ + +# derived by yuyu-book/build.py; CI publishes dist as an artifact +yuyu-book/dist/ +yuyu-book/.cache/ +yuyu-book/cover-art.png +yuyu-book/.scratch/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..098c1e3d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,203 @@ +# 参与修订 + +先读 [STRUCTURE.md](STRUCTURE.md) 了解仓库的两层契约:**Markdown 书稿是唯一内容源,构建器只读不写**;锚点、目录、图号、脚注、书签、页码都在构建时派生。书稿本身可以改,但改动只能由人决定——构建器永不回写正文,v0.3.1 里那三处自相矛盾的数字是人工核对后落的原稿修正,并由 `test_docclaims` 的门禁钉住(正文声明的总数必须等于它下面那张表/那张图)。本文讲环境、命令、改动流程和门禁。 + +## 1. 环境 + +```bash +# Node 22+(package.json 的 engines 要求 >=22),用于 mermaid 解析与图板渲染 +npm ci # 安装 mermaid 11.17.2 + jsdom 26.1.0 + +# Python 侧依赖(Markdown / Pygments / playwright / PyMuPDF / fonttools / Pillow) +python -m pip install -r yuyu-book/requirements.txt +``` + +渲染 Mermaid 图版和打印 PDF 需要一个 Chromium 系浏览器。构建器按 **msedge → chrome → Playwright 自带 chromium** 的顺序自动探测,装了前两者之一就直接可用;都没有时: + +```bash +python -m playwright install chromium # 注意是 Python 包提供的 CLI,不是 npx +``` + +也可以显式指定:`--browser "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"`。**一个坑**:走自动探测分支时构建器会带上 `--allow-file-access-from-files`,而用 `--browser` 指定可执行文件时不会带;如果图板报"harness failed to load",先去掉 `--browser` 让它自动探测。只想跑通构建而不渲染图版,可以用 `--mermaid source`(完全不启动浏览器,图源以代码块排版)。 + +## 2. 常用命令 + +```bash +python yuyu-book/build.py --check # 装配两语版并跑审计,输出进临时目录,不落 dist/、不出 PDF +python yuyu-book/build.py --lang zh # 只出中文版 HTML 到 yuyu-book/dist/ +python yuyu-book/build.py --lang en --pdf # 英文版 HTML + PDF +python yuyu-book/build.py --regen-index # 从附录 D 重建 subject-index.json(随后仍会继续完整构建) +``` + +仓库根 `Makefile` 把常用组合包了一层: + +```bash +make # 列出可用目标 +make book # 双语 HTML +make book-pdf # 双语 HTML + PDF +make check # build.py --check + 两套单测 + scripts/check_book.py + npm run check:diagrams +make index # --regen-index +make clean # 删除 yuyu-book/dist +``` + +两个变量可以覆盖:`make PYTHON=python3`(Linux CI 用)、`make BOOK_LANG=zh`(只出一语;刻意不叫 `LANG`,那是 shell 已经在导出的 locale 变量)。 + +改动前后都建议跑一遍既有内容门: + +```bash +python scripts/check_book.py # 相对链接落点、围栏闭合、Mermaid 里的字面量 \n +npm run check:diagrams # 用锁定的 mermaid 解析器逐图 parse +python -m unittest discover -s tests # 仓库现有单测(star history 等) +python -m unittest discover -s yuyu-book/tests # 构建契约:manifest/来源/围栏与图题/交叉引用/审计/版式 WCAG +``` + +`yuyu-book/tests` 不需要浏览器、不渲染图、不写书稿;它把 `--check` 抓不到的东西钉死:附录 D 表格到索引的映射、`MermaidBlock.line` 必须仍是文件里的行号(图题靠它对位)、`第 N 章` 在 `/
/` 子树里不许被加链、配色 token 的 WCAG 4.5:1 对比度。Windows 控制台若因中文断言报编码错误,加 `PYTHONIOENCODING=utf-8`。
+
+## 3. 新增或修改一章
+
+1. **写 Markdown**,中英文两份都要动。
+2. **首行 H1 必须能被构建器识别**(`core.normalize_h1()` 的模板,围栏内不算):
+   - 中文:`# 前言` / `# 第 2 章:对话循环 -- Agent 的心跳` / `# 附录 A:源码导航地图`
+   - 英文:`# Foreword` / `# Chapter 2: The Dialog Loop -- The Agent's Heartbeat` / `# Appendix A: Architecture Navigation Map`
+   - 冒号可省;正文里的 ` -- `(两侧空格)会被规整成破折号 `—`,连续空格会被压成一个。
+   - 找不到顶层 H1,或第一个 H1 不匹配上面任一模板,直接 `BuildError`。
+3. **在 `yuyu-book/manifest.json` 登记**:`parts[].units[]`(或 `appendices[]`)里加 `{id, zh, en}`;`id` 同时就是成品里的锚点(`#ch09`、`#apxd`),改 `id` 等于改全书链接目标。两个语言路径都必须存在,否则 `validate_configs()` 报错。
+4. **补罗盘条目** `guides[]`:`question` / `deliverable` / `boundary` 三个字段,每个都要 **zh 与 en 双语非空**。校验要求 guides 恰好覆盖所有单元(多一个少一个都不行),`audit` 还要求成品里的罗盘卡数量不少于单元数。
+5. **挂来源** `guides[].sources`:填 `sources.json` 里已有的 id,写错 id 会被 `unknown source id` 拒绝。这些 id 在书里渲染成"来源锚点"chip,指向 `#source-`。
+6. **补通俗层**(可选但强烈建议):`guides[].plain` 是一句章首导读带,`guides[].analogy` 是紧跟其后的"打个比方"。两者都要 zh / en 双语,且必须同时给 `guides[].plain_evidence` —— 一组 `文件:行号` 指针,指回书稿里真正支撑这句话的行。指针落点不能在代码围栏内,那一行还必须含书里真实出现过的词形;导读带要求中英两侧都有证据。写不出证据的白话,就是还没读懂正文,先别写。
+7. **画一枚章徽**:`yuyu-book/illustrations/.svg`(新篇再加 `part-<篇id>.svg`)。不画不会失败——构建器只统计"磁盘上有的图是否都上了页面"——但每个单元的标题行就空着一格。规格见第 5.2 节。
+8. 顺手更新 `yuyu-book/revisions.json` 的版本说明(zh/en 各一份)。
+
+## 4. 新增一条来源(`yuyu-book/sources.json`)
+
+以来源 id 为键的字典条目。id 必须匹配 `^[a-z0-9][a-z0-9-]*$`。
+
+**必填非空字段**:`title`、`authority`、`kind`、`url`、`snapshot`、`accessed`、`license`、`scope`。
+
+**门禁**(`core.validate_configs()`,不满足即构建失败):
+
+- `url` 必须 `https://` 开头。
+- `kind: github` → `pinned` 里必须有 `tag` 或 `commit` 之一。
+- `kind: npm` → `pinned.version` 必填。
+- `kind: community` → 必须 `normative: false` 且有 `accessed`;若给了 `pinned.commit`,必须是完整 40 位 SHA(长度校验目前只对 community 生效)。
+
+**约定**(构建器不检查,但请遵守):
+
+- GitHub 条目按现有做法写完整 40 位 SHA,而不是分支名——`main` 会移动,叙述就无法回溯。现有 6 条 github 条目全是完整 SHA。
+- `snapshot` 写清读取时间与核对方式(例:"2026-10-25 读取该 commit 下的 CHANGELOG.md,HTTP 200,首条版本号 2.1.310");`accessed` 写日期;`scope` 写这条来源用来核对全书的哪类叙述。**只登记书目元数据,不转载来源正文**——这也是 `license` 字段一律写"链接引用,不转载"的原因。
+- Anthropic 的源码、文档、博客与 changelog 一律链接引用,不复制进书稿或成品。
+- 不要把 GPL 系许可的资产(图、字体、代码片段)打包进书的单文件成品:本书文字是 CC BY-NC-SA 4.0,非商业 + 相同方式共享的条款与 copyleft 强传染条款混装会两头都不干净。
+
+## 5. 书稿行文规范
+
+这些规则决定了成品能不能过审计、以及排版是否成立。
+
+**中文排版**
+
+- 全角标点,中英文之间留半角空格;术语首次出现给中英对照。
+- 正文不使用 emoji。图注编号、罗盘卡与提示框的图形标记(✦ ⚠ ↗ ◎ ※)由渲染器注入,不需要也不应该在 Markdown 里手写。
+- 表格里的排版符号(✓ ✗ ★ ☆)可以用。
+
+**代码围栏**
+
+- 必须闭合,未闭合会报 `unclosed code fence at line N`,并且审计会以"`
`/`` 内嵌块级元素"再抓一次。
+- **声明语言**:` ```json `、` ```typescript `、` ```bash ` 等。围栏 info 串会成为代码板右上角的语言角标(`data-lang`);不写语言的围栏拿不到角标,且按纯文本高亮。围栏数量与渲染出的代码块数量必须一一对应,否则报"rendered code blocks but N source fences"。
+- Mermaid 围栏固定写 ` ```mermaid `。图题取自动态扫描的**最近上层 H2**,所以每张图前要有一个 H2 标题;没有就回落成"架构示意"。图内换行用 `
`,写字面量 `\n` 会被 `scripts/check_book.py` 拦下。 +- **图的宽度**:Mermaid 按自己的坐标排版,正文栏只有 635px。viewBox 宽超过 1150 单位的图版会被 `floor_wide_svg()` 钉上 `min-width`(最宽 2600px),屏幕上横向拖动阅读、图题带"可横向拖动"提示,打印时归零回到整页可见。所以画超大依赖图前先想清楚:横向铺开 = 读者要拖,宁可拆成两张 H2 下的小图。 +- **图在纸面上的尺寸**:纸面没有滚动条,只有两块可用地——竖版版心 176×245mm(665×926 CSS px)与横版折页 170mm 高(993×642 CSS px)。构建时 `plate_cap()` 给"很高、按 6pt 字号底线缩放后正好还放得进栏宽"的图写一条 `--plate-cap`(150–245mm),让它独占版面而不是被压小——成品里中文版抬了 6 张、英文版 9 张;`plate_landscape()` 把"横过来才读得清"的图(竖着不足 6pt、横着能到 5pt 且好上一成半)挂上 `landscape` 类,独占一张 `@page plate-landscape` 横向 A4——中文版 15 张、英文版 22 张,一张图一张纸。折页**不写** `--plate-cap`:横版纸面只有 170mm 高,图版吃满 165mm 就会把图题挤到下一张纸上。两头都够不着的极端长条仍留在正文流里,纸面上就是小:中文版还剩 1 张(3592×1330 单位,约 2.5pt),英文版 3 张。**HTML 才是全保真产物**,这类图请引导读者在屏幕上横向拖开看。 +- **三个会把整本书的图打崩的写法**:别给 `.diagram-frame` 或图版容器加 `width:fit-content`——mermaid 的 SVG 根节点写的是 `width="100%"`,百分比宽度在 fit-content 父级里塌成 0,157 张图会集体消失(`test_build_contracts` 里有一条负向断言盯着它);别加 `filter:`——打印引擎会把滤镜子树光栅化,图里的字退化成像素(PDF 里选不中、搜不到),文件体积还能翻倍;也别在 `figure.landscape` 上写 `break-after:page`——命名页面本身就会结束这一张纸,两个都写会在每个折页后面多出一张空白。 +- **图的颜色**:优先不写颜色,`mermaid.py` 的 `THEME` 会把无主节点排成书色。已经写了 `classDef`/`style` 的图也不必回改正文——构建时 `reink_colors()` 按色相把 fill/stroke/color 重染进 `BOOK_FAMILIES` 七组书色(蓝→sky、绿→jade、橙黄→brass、红→cinnabar、紫→plum、近白→card、其余→neutral),角色区分保留,色板统一。写新图时按语义选色即可,别指望某个具体十六进制出现在成品里。 + +**引用与链接** + +- 跨章引用直接写"第 9 章""附录 B"(英文写 `Chapter 9` / `Appendix B`),构建时自动加链(覆盖第 1–15 章与附录 A–D);调试时可加 `--no-autolink`。 +- 不要在正文里写指向 `.md` 的相对链接——书稿之间的互指应通过"第 N 章"表达,否则审计报 `residual .md links`。 +- 不要手写锚点、图号、脚注编号:它们全部派生自 Markdown 标题与链接顺序。 +- 外链写成普通 Markdown 链接即可,构建器会自动加上标编号并在单元末尾生成"引用链接"清单。 + +**提示框(callout)** + +以 `> **标签:** 正文` 开头的引用块会被识别成对应色板的 callout,标签必须命中内置映射(括号说明会被剥掉后比较): + +| 标签 | 类型 | +|------|------| +| 最佳实践 / 正确做法 / 推荐做法 | tip | +| 反模式警告 / 危险 / 注意 | warning | +| 交叉引用 / 交叉引用提示 | info | +| 设计洞察 / 原理 | concept | +| best practice / correct approach | tip | +| anti-pattern warning / warning | warning | +| cross-reference / extended reflection / practical application scenarios | info | +| memories that should be saved | tip | +| memories that should not be saved | warning | +| design insight | concept | +| learning objectives / reference answer(s) / reference answer hints | note(硬编码兜底列表) | + +命中不了的标签会保持成普通引用块,不会报错——所以想真正出框,请照抄上表的标签写法。 + +**双语同步** + +`en/` 下的英文镜像与中文逐单元对应,章节标题模板不同但结构一致;只改中文不改英文,虽然不会立刻失败,但会让两语版的图数、脚注数、索引词条数对不上,评审时请一并说明差异。 + +### 5.1 通俗层:白话注、导读带、阅读路线 + +通俗层是构建时叠在书稿上的一层,**从不回写 Markdown**。四个落点: + +**术语白话注**(`yuyu-book/glosses.json`)一条一个术语:`term.{zh,en}`、`forms.{zh,en}`(正文里要匹配的表面写法,大小写与连字符照抄)、`plain.{zh,en}`(一句能懂的话)、`analogy`(可选比方)、`priority`、`evidence`。只有 `priority: "core"` 的词会在正文首现处画虚线,而章末面板列的就是这批画了线的词——两处是同一批,构建时断言,不存在「只进面板不上正文」的中间档。`"extra"` 的词两者都不沾,只在附录 D 可查。一个单元最多 22 个新词,超了构建失败——那时把最通用的词降成 `extra`,而不是删掉解释(本书正是这样给 `Agent Harness` 与主循环腾出位置的)。 + +**导读带与比方**在 `guides[].plain` / `.analogy`,配 `guides[].plain_evidence`。长度上限(超出即 `BuildError`):导读带 zh 60 字 / en 170 字符,比方 zh 52 / en 160;浮窗里的白话 zh 40 / en 130,比方 zh 44 / en 130。 + +**阅读路线**(`yuyu-book/reading-paths.json`)按单元存一个小节顺序的位置列表,取值 `start` / `core` / `deep`(成品里渲染成"起步 / 主干 / 进阶")。长度必须等于该单元实际渲染出的 `

` 数:加了小节忘了同步,构建会直接报错,而不是把徽盖到别的小节上。 + +**按语**(`yuyu-book/notes.json`)以单元 id 为键,`{zh, en}` 各一句,由坐在章末车辙上方的老驿卒说出来,书末「按语集」再把二十条按阅读顺序收成一页。门禁是 `plain.validate_notes()`:中文 ≤36 字、英文 ≤130 字符,英文里不许夹汉字,二十个单元缺一条或多一条没主的都让构建失败;最要紧的是**术语锁**——句子里必须有一个 `glosses.json` 登记过的词形,而且那个词形要真的站在这一单元自己的 Markdown 里(构建器读的是源文件,不认「意思差不多」:第 1 章正文写的是 `Agent Harness`,写「智能体线束」会被退回;第 14 章写「规划模式」,写「计划模式」同样被退回)。旁白可以借书自己的比喻,不许凭空造词,也不许替书稿发表它没说过的论断——这是这一层与「白话不能以说错为代价」同一条底线的另一面。按语里也宁可不写数字:写了,`test_docclaims` 就要拿它跟仓库重算出来的值对表(`KEEPER_TOTALS` 目前钉住附录 B 那行的工具总数,要写别的数先去那里登记它从哪算来);把数目写成汉字(「五十五」「六十多」)等于对一切门禁隐身——这本书正是被一个写成汉字的约数骗过的,所以这条规矩先管到自己这一层。 + +整层可以 `--no-plain` 关掉,用来对比开合前后的成品。 + +**底线:通俗不能以说错为代价。** 每一句白话都要能在书稿里指出行号;`validate_plain()` 会逐条核对证据指针落在代码围栏之外、那一行确实含登记过的词形,core 词条与每条导读带要求中英两侧都有证据。写不出证据的白话,就是还没读懂正文——先别写。 + +### 5.2 插图:章徽、篇题刻与扉页车图 + +- 位置与命名:`yuyu-book/illustrations/.svg`(20 枚章徽)、`illustrations/part-.svg`(4 幅题刻)、`illustrations/cover.svg`(扉页那幅四马单辕古车,1000×470)、`illustrations/keeper.svg`(章末那枚老驿卒的斗笠与灯笼,64×64,每章内联一次,测试把它的体积钉在 3 KB 以内)。 +- 章徽是 64×64 的单线小图、描边 2.1;题刻是 200×140 的小版画、描边 1.5;车图铺满整个版面、按层次分档描线(轮与辕 6.5/6,马身 4,辐与细部 1.2–2)。屏幕上题刻约 216px 宽、章徽 74px,三者不该用同一档笔画粗细。 +- 硬约束(`art.py` 的装载器逐条把关,违反即构建失败):只用 `currentColor` 上色——一张图同时管屏幕、暗色模式与纸面,不需要第二份文件;不得出现 `id=`(同一份图会被内联进单文件几十次,一个 id 就是一次重复 id,审计会在离图很远的地方炸)、`href=`、`url(`、`' + '') + out, floor = render.floor_wide_svg(svg) + self.assertEqual(floor, "1700px") + self.assertIn('', out) + self.assertIn("", out) + self.assertIn('', out) + + +class DiagramsSurviveRendering(unittest.TestCase): + """End-to-end for one synthetic unit: placeholder -> figure, no
."""
+
+    def render(self, text, lang="zh", uid="ch01", kind="chapter"):
+        _no, _t, body = normalize_h1(text, lang, "t.md")
+        body, blocks, langs = extract_mermaid(body, "t.md")
+        caps = mm.captions_for(text, blocks)
+        unit = Unit(id=uid, kind=kind, lang=lang, no=_no, title=_t,
+                    md=body, blocks=blocks, code_langs=langs, path="t.md",
+                    anchor=uid)
+        mapping = {i: i for i in expected_ids(load_configs()[0])}
+        return render.render_unit(unit, mapping,
+                                  diagrams=mm.source_fallback(blocks, caps))
+
+    def test_prose_after_a_diagram_is_not_swallowed_by_a_pre(self):
+        payload = self.render(
+            "# 第 1 章 标题\n\n```mermaid\ngraph LR\nA-->B\n```\n\n"
+            "这段是普通段落,不该在 pre 里。\n")
+        html = payload["html"]
+        self.assertEqual(payload["n_diagrams"], 1)
+        self.assertNotIn("@@MM:", html)
+        self.assertNotIn("```", html)
+        self.assertIn("

这段是普通段落,不该在 pre 里。

", html) + pre = re.search(r"]*>(.*?)
", html, re.S) + self.assertIn("graph LR", pre.group(1)) # the mermaid source + self.assertNotIn("普通段落", pre.group(1)) + + def test_missing_diagram_is_marked_not_dropped(self): + payload = self.render("# 第 1 章 标题\n\n```mermaid\nA-->B\n```\n") + unit = payload["unit"] + html = render.render_unit(unit, {}, diagrams={})["html"] + self.assertIn('class="mermaid-missing"', html) + self.assertNotIn("@@MM:", html) + + def test_figures_are_numbered_by_chapter_and_run(self): + payload = self.render( + "# 第 7 章 上下文\n\n```mermaid\nA-->B\n```\n\n```mermaid\nC-->D\n```\n") + self.assertEqual(payload["n_diagrams"], 2) + self.assertIn(">图 7-1<", payload["html"]) + self.assertIn(">图 7-2<", payload["html"]) + + def test_appendix_figures_are_numbered_with_the_letter(self): + payload = self.render("# 附录 A:地图\n\n```mermaid\nA-->B\n```\n", + uid="apxa", kind="appendix") + self.assertIn(">图 A1<", payload["html"]) + + def test_diagram_uids_do_not_collide_across_the_two_languages(self): + manifest, _sources = load_configs() + seen = {} + for lang in ("zh", "en"): + for unit_def, kind, _part in unit_stream(manifest): + u = load_unit(unit_def, kind, lang, REPO_ROOT) + for b in u.blocks: + seen.setdefault(b.uid, set()).add((lang, u.id)) + self.assertEqual(sum(1 for v in seen.values() if len(v) > 1), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/yuyu-book/tests/test_notes.py b/yuyu-book/tests/test_notes.py new file mode 100644 index 00000000..6d6bd5c4 --- /dev/null +++ b/yuyu-book/tests/test_notes.py @@ -0,0 +1,152 @@ +"""The keeper layer (按语层): one line of marginalia per unit. + +An old postman sits at the foot of every chapter, above its wheel ruts, and +looks back over the leg just travelled. Commentary is not a claim about the +manuscript, so its gate is lighter than the why layer's — but it is not +nothing: a note must contain a registered term form that really stands in +that unit's own text (术语锁), stay one sentence, exist in both languages, +and never mix Han characters into the English edition. + +Synthetic rules first; the shipped notes.json is asserted against the real +book in ShippedNotes. +""" +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from yuyu_book import art, pages, plain # noqa: E402 +from yuyu_book.core import (BOOK_DIR, REPO_ROOT, BuildError, load_json, # noqa + read_text, unit_stream) # noqa: E402 + +GLOSSARY = { + "main-loop": { + "term": {"zh": "主循环", "en": "Main Loop"}, + "forms": {"zh": ["主循环", "Main Loop"], + "en": ["Main Loop", "main loop"]}, + "plain": {"zh": "一遍一遍转的那个圈", "en": "the circle that turns"}, + "priority": "core", + }, + "faraway": { + "term": {"zh": "远方概念", "en": "Faraway Concept"}, + "forms": {"zh": ["远方概念"], "en": ["faraway concept"]}, + "plain": {"zh": "别处的东西", "en": "a thing from elsewhere"}, + "priority": "extra", + }, +} +UNIT_TEXTS = { + "ch02": {"zh": "主循环每转一圈,就少一段路。", + "en": "Every turn of the main loop is a mile less."}, +} + + +def note(zh="主循环走一圈,路就短一天。", + en="Every turn of the main loop is a day less of road."): + return {"ch02": {"zh": zh, "en": en}} + + +class NoteRules(unittest.TestCase): + def test_a_note_holding_a_term_of_its_unit_passes(self): + plain.validate_notes(note(), GLOSSARY, UNIT_TEXTS) + + def test_a_note_without_any_registered_form_fails(self): + with self.assertRaisesRegex(BuildError, "术语锁"): + plain.validate_notes(note(zh="天色不早了,该收车了。"), + GLOSSARY, UNIT_TEXTS) + + def test_a_form_that_is_not_in_this_units_text_fails(self): + """The lock is per unit: a form registered somewhere in the book but + absent from this chapter's own text does not unlock its note.""" + with self.assertRaisesRegex(BuildError, "术语锁"): + plain.validate_notes(note(zh="远方概念在这里并不出现。"), + GLOSSARY, UNIT_TEXTS) + + def test_the_english_note_is_locked_by_the_english_forms(self): + with self.assertRaisesRegex(BuildError, "术语锁"): + plain.validate_notes(note(en="天色不早,收车。没有英文词形。"), + GLOSSARY, UNIT_TEXTS) + + def test_a_too_long_note_fails(self): + long = "主循环" + "绕" * plain.NOTE_LIMITS["zh"] + with self.assertRaisesRegex(BuildError, "上限"): + plain.validate_notes(note(zh=long), GLOSSARY, UNIT_TEXTS) + + def test_an_english_note_may_not_carry_han(self): + with self.assertRaisesRegex(BuildError, "汉字"): + plain.validate_notes(note(en="the main loop 绕了一圈"), + GLOSSARY, UNIT_TEXTS) + + def test_every_unit_needs_its_note_and_no_unit_invents_one(self): + with self.assertRaisesRegex(BuildError, "ch03"): + plain.validate_notes(note(), GLOSSARY, + {**UNIT_TEXTS, "ch03": {"zh": "", "en": ""}}) + with self.assertRaisesRegex(BuildError, "ch02"): + plain.validate_notes({}, GLOSSARY, UNIT_TEXTS) + + def test_an_empty_language_fails(self): + bad = {"ch02": {"zh": "主循环。", "en": " "}} + with self.assertRaisesRegex(BuildError, "en"): + plain.validate_notes(bad, GLOSSARY, UNIT_TEXTS) + + +class NotesPage(unittest.TestCase): + def test_the_page_prints_each_note_once_with_a_way_back(self): + units = [SimpleNamespace(id="ch02", anchor="ch02", display="第 2 章")] + html = pages.notes_page(note(), units, "zh") + self.assertIn('id="keeper-notes"', html) + self.assertIn("主循环走一圈,路就短一天。", html) + self.assertIn('href="#ch02"', html) + self.assertEqual(html.count('
  • 第 2 章", html) + self.assertIn('title="第 2 章 对话循环 — Agent 的心跳"', html) + + +@unittest.skipUnless((art.ART_DIR / "keeper.svg").exists(), + "no keeper drawing yet") +class KeeperDrawing(unittest.TestCase): + def test_the_keeper_passes_the_loader_and_reaches_the_page(self): + block = art.keeper() + self.assertTrue(block.startswith('a


    b

    ") + self.assertIn('class="hr-rule"', out) + self.assertIn("currentColor", out) + self.assertNotIn("", 1)[0]) + + def test_the_road_has_a_ring_for_every_chapter_and_a_dot_for_the_siding(self): + chapters = sum(len(p["units"]) for p in self.manifest["parts"]) + self.assertEqual(self.svg.count('r="3.6"'), chapters, chapters) + self.assertEqual(self.svg.count('r="2.7"'), + len(self.manifest["appendices"])) + + def test_the_wheel_at_the_head_carries_the_foreword_number(self): + self.assertEqual(self.svg.count(f'>{pages.FRONT_NO}'), 1) + self.assertEqual(self.svg.count(f'>{self.nos["ch01"]}'), 1) + + def test_the_map_is_the_same_map_every_build(self): + again = pages.route_map_page(self.manifest, "zh", self.nos) + self.assertEqual(again, self.html) + + def test_the_list_under_the_map_links_every_leg(self): + want = [self.manifest["front"]["id"]] + \ + [p["id"] for p in self.manifest["parts"]] + \ + [self.manifest["appendices"][0]["id"]] + got = re.findall(r'
  • ", html), "no bullet in revision list") + + def test_revision_notes_are_typeset_not_left_in_markdown(self): + """The notes quote the builder's own names; a row of raw backticks + around one reads as a typo in a book that sets code spans properly.""" + entries = load_json(BOOK_DIR / "revisions.json")["entries"] + for lang in ("zh", "en"): + html = pages.revisions_page(entries, lang) + self.assertNotIn("`", html, "a backtick reached the page") + self.assertIn("pdf_meta()", html) + self.assertNotIn("", html) + + def test_a_note_naming_a_long_flag_still_fits_a_narrow_screen(self): + """Front matter never runs the unit pipeline, so the break points have + to be put in over the assembled page: `getFeatureValue_CACHED_MAY_BE_STALE` + in a revision note pushed the document 37px past a 390px screen.""" + entries = load_json(BOOK_DIR / "revisions.json")["entries"] + for lang in ("zh", "en"): + out = render.soften_long_names(pages.revisions_page(entries, lang)) + self.assertIn("", out, "no break point in a long name") + self.assertNotIn("", out) + self.assertIn("getFeatureValue_CACHED_MAY_" + "BE_STALE", out) + + def test_reading_routes_link_to_the_first_chapter_of_each_part(self): + html = pages.how_to_read(self.manifest, "zh") + self.assertIn('id="how-to-read"', html) + for part in self.manifest["parts"]: + self.assertIn(f'href="#{part["units"][0]["id"]}"', html) + self.assertEqual(html.count("route-card"), len(self.manifest["start_reading"])) + + def test_part_divider_lists_its_own_chapters(self): + part = self.manifest["parts"][1] + rows = [(u["id"], u["id"][-2:], u["id"].upper()) for u in part["units"]] + html = pages.part_divider(part, "zh", 2, rows) + self.assertEqual(f'id="{part["id"]}"', re.search(r'id="(p\d)"', html).group(0)) + for anchor, no, label in rows: + self.assertIn(f'href="#{anchor}"', html) + self.assertIn(f'data-no="{no}">{label}', html) + self.assertIn("Ⅱ", pages._ROMAN[2]) + + def test_part_divider_draws_the_road_before_its_stops(self): + """The strip above the list is the part's itinerary: one ring for every + chapter the divider then names, so the count is not decoration.""" + part = self.manifest["parts"][1] + rows = [(u["id"], u["id"][-2:], u["id"].upper()) for u in part["units"]] + html = pages.part_divider(part, "zh", 2, rows) + self.assertEqual(html.count('r="3.3"'), len(rows)) + self.assertLess(html.index("pd-route"), html.index("pd-list")) + + +class Compass(Config): + def test_three_cards_and_cited_source_chips(self): + guide = self.manifest["guides"]["ch07"] + html = pages.compass(self.units["ch07"], guide, self.sources, "zh") + for key in ("question", "deliverable", "boundary"): + self.assertIn(f'compass-card {key}', html) + self.assertIn(guide[key]["zh"], html) + self.assertEqual(html.count("source-chip"), len(guide["sources"])) + for sid in guide["sources"]: + self.assertIn(f'href="#source-{sid}"', html) + + def test_english_compass_uses_english_copy(self): + guide = self.manifest["guides"]["ch01"] + html = pages.compass(self.units["ch01"], guide, self.sources, "en") + self.assertIn(guide["question"]["en"], html) + self.assertNotIn(guide["question"]["zh"], html) + + def test_chips_point_at_real_registry_entries(self): + registry = pages.source_registry(self.sources, "zh") + for uid, guide in self.manifest["guides"].items(): + for sid in guide.get("sources", []): + self.assertIn(f'id="source-{sid}"', registry, uid) + + +class Contents(Config): + def _entries(self): + entries = [{"kind": "row", "anchor": "front", "no": "00", "title": "前言"}] + for i, part in enumerate(self.manifest["parts"], 1): + entries.append({"kind": "part", "anchor": part["id"], + "no": pages._ROMAN[i], "title": part["title"]["zh"]}) + for u in part["units"]: + unit = self.units[u["id"]] + entries.append({"kind": "row", "anchor": u["id"], + "no": unit.no_plain, "title": unit.title}) + entries.append({"kind": "hr"}) + for apx in self.manifest["appendices"]: + unit = self.units[apx["id"]] + entries.append({"kind": "row", "anchor": apx["id"], + "no": unit.no_plain, "title": unit.title}) + return entries + + def test_every_entry_gets_a_page_ref_placeholder(self): + html = pages.toc_html(self._entries(), "zh") + for e in self._entries(): + if e["kind"] == "hr": + continue + self.assertIn(f'data-ref="{e["anchor"]}"', html, e["anchor"]) + self.assertIn('class="page-ref', html) + + def test_parts_and_their_rows_group_so_a_page_break_cannot_strand_a_heading(self): + html = pages.toc_html(self._entries(), "zh") + groups = re.findall(r'

    (.*?)
    \s*(?=
    |)', + html, re.S) + self.assertGreaterEqual(len(groups), 5) + for group in groups: + parts = re.findall(r'class="toc-part"', group) + self.assertLessEqual(len(parts), 1, "one heading per group") + + def test_appendix_gutter_labels_are_letters_in_both_languages(self): + for apx in self.manifest["appendices"]: + for lang in ("zh", "en"): + u = load_unit(apx, "appendix", lang, REPO_ROOT) + self.assertEqual(u.no_plain, apx["id"][-1].upper(), + f"{apx['id']} {lang}: {u.no_plain}") + + def test_chapter_gutter_labels_are_zero_padded_numbers(self): + for part in self.manifest["parts"]: + for u in part["units"]: + unit = self.units[u["id"]] + self.assertRegex(unit.no_plain, r"^\d{2}$", u["id"]) + + +class BackMatter(Config): + def test_registry_groups_by_kind_and_counts_every_source(self): + html = pages.source_registry(self.sources, "zh") + self.assertIn('id="sources"', html) + for sid in self.sources: + self.assertIn(f'id="source-{sid}"', html) + self.assertEqual(html.count('class="reg-list"'), + len({s["kind"] for s in self.sources.values()})) + + def test_registry_urls_are_https_and_rel_noopener(self): + html = pages.source_registry(self.sources, "en") + for href in re.findall(r'([^<]*(?:[^<]*)*)', + html) + self.assertIsNotNone(row, "no underscore term reached the index") + self.assertIn("_", html) + self.assertNotIn("", html) + self.assertNotIn("<wbr>", html, "the break tag got escaped away") + + def test_index_entries_are_sorted_and_lettered(self): + entries = load_json(BOOK_DIR / "subject-index.json") + letters = [e["letter"] for e in entries] + self.assertEqual(letters, sorted(letters)) + for e in entries[:20]: + self.assertTrue(e["zh"]["refs"] or e["en"]["refs"]) + self.assertTrue(e["key"].strip()) + + def test_a_chinese_reader_can_search_in_chinese(self): + """Appendix D files each term under its English name, which is the + right heading for the English book and the wrong one for this: nobody + who has just read 自动压缩 in ch 7 thinks to look under A. The + translation column is a headword too, so it gets its own block.""" + entries = load_json(BOOK_DIR / "subject-index.json") + html_zh = pages.subject_index_page(entries, "zh") + terms = re.findall(r'([^<]*)', html_zh) + cn = [t for t in terms if re.search(r"[\u4e00-\u9fff]", t)] + self.assertGreater(len(cn), 60, + "the Chinese headwords never reached the index") + self.assertIn("自动压缩", cn) + heads = re.findall(r'

    ([^<]*)

    ', html_zh) + self.assertEqual([h for h in heads if not re.fullmatch(r"[A-Z]", h)], + ["中文"], "the Chinese block needs its own heading") + # nothing else in the book is filed by radical, so the block has to say + # how it is ordered or it reads as a pile + note = re.search(r'

    ([^<]*)

    ', html_zh) + self.assertIsNotNone(note, "the Chinese block shipped without its rule") + self.assertIn("部首", note.group(1)) + + def test_the_english_index_does_not_inherit_chinese_rows(self): + entries = load_json(BOOK_DIR / "subject-index.json") + html_en = pages.subject_index_page(entries, "en") + for t in re.findall(r'([^<]*)', html_en): + self.assertNotRegex(t, r"[\u4e00-\u9fff]", + "a Chinese-only row leaked into the English index") + self.assertNotIn("中文", html_en) + + def test_paper_reserves_the_folio_slot_the_pdf_fills(self): + """A screen has no pages, so an index row shows only its locator chip and + the row's folio placeholder stays hidden. Paper is the other case: the + placeholder exists in the markup, but the print block inherited the + screen's `display:none` and the PDF pass had no row list to stamp, so the + slot was neither reserved nor filled.""" + entries = load_json(BOOK_DIR / "subject-index.json") + printed = [e for e in entries if e["zh"]["refs"]] + html = pages.subject_index_page(entries, "zh") + rows = re.findall(r'
  • .*?
  • ', html, re.S) + self.assertEqual(len(rows), len(printed), + "a row this edition lists never reached the page") + for row, e in zip(rows, printed): + self.assertEqual(row.count('class="page-ref idx-ref"'), 1, e["key"]) + self.assertIn(f'data-ref="{e["zh"]["refs"][0]["anchor"]}"', row, + e["key"]) + print_block = CSS[CSS.index("@media print{"):] + self.assertRegex(print_block, r"\.idx-row \.page-ref\{display:block") + + def test_the_meta_row_list_is_the_index_in_printed_order(self): + """The folio pass is handed terms, not positions: page one of the PDF + tells it which sheet each anchor landed on, the numbers are written into + the slots the rows already reserve, and page two prints the book. If + `pdf_meta` and the rendered page disagree, every number lands on somebody + else's headword.""" + import build + from html import unescape + entries = load_json(BOOK_DIR / "subject-index.json") + for lang in ("zh", "en"): + printed = [e for e in entries if e[lang]["refs"]] + html = pages.subject_index_page(entries, lang) + rendered = [unescape(t).replace("_", "_") for t in + re.findall(r'(.*?)', html, re.S)] + rows = build.pdf_meta(self.manifest, [], [], lang, entries)["index_rows"] + self.assertEqual([r["term"] for r in rows], rendered, lang) + self.assertEqual([r["anchor"] for r in rows], + [e[lang]["refs"][0]["anchor"] for e in printed], lang) + + def test_folios_land_in_the_slot_of_their_own_row(self): + """A row's slot is keyed by the anchor it links to, so `SessionStart` + cannot borrow `Session`'s number however similar the two lines print.""" + html = ('
  • Session' + '
  • ' + '
  • SessionStart' + '
  • ') + filled = pages.fill_index_folios(html, {"ch07": 91, "ch08": 121}) + self.assertIn('data-ref="ch07">91', filled) + self.assertIn('data-ref="ch08">121', filled) + self.assertEqual(filled.count('class="page-ref idx-ref"'), 2) + + def test_an_unfilled_or_double_slot_fails_the_build(self): + """The slot is the only place a folio may go: a row left blank prints an + index that stops mid-column, and a number written twice would mean two + rows claim one anchor.""" + html = ('
  • ' + '
  • ') + from yuyu_book.core import BuildError + with self.assertRaises(BuildError): + pages.fill_index_folios(html, {"ch07": 91}) + with self.assertRaises(BuildError): + pages.fill_index_folios(html, {"ch07": 91, "ch09": 99, "ch11": 5}) + # the empty slot survives untouched in the source, so a screen reader + # never gets a page number that only a printed sheet can know + self.assertNotIn(">91<", html) + + +class Principles(Config): + """The reason layer on paper: a hover card never reaches the PDF, so every + core term's `why` has to be printed somewhere, filed under the chapter that + argues for it.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + import build + cls.build = build + cls.glosses, _paths = plain.load_plain() + + def _groups(self, lang="zh"): + units = [load_unit(u, k, lang, REPO_ROOT) + for u, k, _p in unit_stream(self.manifest)] + return self.build._principle_groups(self.glosses, units, lang) + + def test_every_reason_the_html_hovers_is_printed_once(self): + groups = self._groups() + printed = sum(len(g["terms"]) for g in groups) + owed = sum(1 for g in self.glosses.values() + if (g.get("why") or {}).get("zh")) + self.assertEqual(printed, owed) + self.assertEqual(owed, sum(1 for g in self.glosses.values() + if g.get("priority") == "core"), + "a core term is missing its reason line") + + def test_the_page_files_each_reason_under_a_real_unit(self): + ids = set(expected_ids(self.manifest)) + html = pages.principles_page(self._groups(), "zh") + self.assertIn('id="principles"', html) + for href in re.findall(r'
    .*?', html, re.S).group(0) + self.assertIn('为什么', row) + self.assertIn('比方', row) + self.assertLess(row.index("pr-why"), row.index("pr-ana"), + "the picture must follow the reason, not lead it") + + def test_the_english_page_leaks_no_chinese(self): + html = pages.principles_page(self._groups("en"), "en") + self.assertEqual(sorted(set(re.findall(r"[一-鿿]", html))), [], + "the English principles page printed Chinese text") + + def test_a_reason_pointer_outside_the_book_is_a_build_failure(self): + glosses = {"ag": {"term": {"zh": "异步生成器", "en": "async generator"}, + "why": {"zh": "一趟要交很多次", "en": "many results"}, + "why_evidence": ["nowhere/missing.md:1", + "en/nowhere/missing.md:1"]}} + with self.assertRaises(BuildError): + self.build._principle_groups(glosses, list(self.units.values()), + "zh") + + +class Shell(unittest.TestCase): + def test_page_shell_is_js_free_and_offline(self): + html = page("zh", "御舆", CSS, "lang-zh", "

    x

    ", "", "") + self.assertIn('', html) + self.assertIn(" + + +{rail} +
    +{content} +{colophon} +
    + +""" diff --git "a/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/01-\346\231\272\350\203\275\344\275\223\347\274\226\347\250\213\347\232\204\346\226\260\350\214\203\345\274\217.md" "b/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/01-\346\231\272\350\203\275\344\275\223\347\274\226\347\250\213\347\232\204\346\226\260\350\214\203\345\274\217.md" index d0e73a8c..c269ba9e 100644 --- "a/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/01-\346\231\272\350\203\275\344\275\223\347\274\226\347\250\213\347\232\204\346\226\260\350\214\203\345\274\217.md" +++ "b/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/01-\346\231\272\350\203\275\344\275\223\347\274\226\347\250\213\347\232\204\346\226\260\350\214\203\345\274\217.md" @@ -172,7 +172,7 @@ flowchart TD p1["预处理管线
    压缩/裁剪"] --> p2["API 调用
    流式接收"] --> p3["工具检测
    权限检查"] --> p4["状态构建
    消息回填"] end - tools["工具系统
    45+ 工具 · 编排引擎 · 并发分区"] + tools["工具系统
    55 个工具 · 编排引擎 · 并发分区"] perm["权限管线
    四阶段检查 · 五种模式 · 规则持久化"] ext["扩展层
    MCP 协议 · 子智能体调度 · 插件系统 · Hook 机制"] diff --git "a/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/03-\345\267\245\345\205\267\347\263\273\347\273\237-Agent\347\232\204\345\217\214\346\211\213.md" "b/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/03-\345\267\245\345\205\267\347\263\273\347\273\237-Agent\347\232\204\345\217\214\346\211\213.md" index 71ffa3fa..f50e36a7 100644 --- "a/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/03-\345\267\245\345\205\267\347\263\273\347\273\237-Agent\347\232\204\345\217\214\346\211\213.md" +++ "b/\347\254\254\344\270\200\351\203\250\345\210\206-\345\237\272\347\241\200\347\257\207/03-\345\267\245\345\205\267\347\263\273\347\273\237-Agent\347\232\204\345\217\214\346\211\213.md" @@ -5,7 +5,7 @@ **学习目标:** 阅读本章后,你将能够: -- 掌握 Claude Code 45+ 工具的设计模式,理解五要素协议的设计哲学 +- 掌握 Claude Code 55 个已注册工具的设计模式,理解五要素协议的设计哲学 - 理解工具定义协议、注册机制、编排引擎的完整架构 - 分析并发分区策略的调度原理和实际效果 - 理解 StreamingToolExecutor 四阶段状态机的精妙设计 @@ -13,7 +13,7 @@ --- -Maslow 的这句话用在 Agent 工具系统上再贴切不过。如果 Agent 只有一个 Bash 工具,所有任务都会变成 Shell 命令——读取文件用 `cat`,搜索代码用 `grep`,编辑文件用 `sed`。这虽然可行,但违反了"使用正确工具解决正确问题"的工程原则。Claude Code 的工具系统提供了 45+ 个专门化工具,每个工具针对特定的操作类型做了优化——这就好比为不同任务配备不同的专业工具,而不是用一把锤子解决所有问题。 +Maslow 的这句话用在 Agent 工具系统上再贴切不过。如果 Agent 只有一个 Bash 工具,所有任务都会变成 Shell 命令——读取文件用 `cat`,搜索代码用 `grep`,编辑文件用 `sed`。这虽然可行,但违反了"使用正确工具解决正确问题"的工程原则。Claude Code 的工具系统注册了 55 个专门化工具(完整清单见附录 B),每个工具针对特定的操作类型做了优化——这就好比为不同任务配备不同的专业工具,而不是用一把锤子解决所有问题。 ## 3.1 工具定义协议 diff --git "a/\347\254\254\344\272\214\351\203\250\345\210\206-\346\240\270\345\277\203\347\263\273\347\273\237\347\257\207/08-\351\222\251\345\255\220\347\263\273\347\273\237-Agent\347\232\204\347\224\237\345\221\275\345\221\250\346\234\237\346\211\251\345\261\225\347\202\271.md" "b/\347\254\254\344\272\214\351\203\250\345\210\206-\346\240\270\345\277\203\347\263\273\347\273\237\347\257\207/08-\351\222\251\345\255\220\347\263\273\347\273\237-Agent\347\232\204\347\224\237\345\221\275\345\221\250\346\234\237\346\211\251\345\261\225\347\202\271.md" index 92f502b1..f62458db 100644 --- "a/\347\254\254\344\272\214\351\203\250\345\210\206-\346\240\270\345\277\203\347\263\273\347\273\237\347\257\207/08-\351\222\251\345\255\220\347\263\273\347\273\237-Agent\347\232\204\347\224\237\345\221\275\345\221\250\346\234\237\346\211\251\345\261\225\347\202\271.md" +++ "b/\347\254\254\344\272\214\351\203\250\345\210\206-\346\240\270\345\277\203\347\263\273\347\273\237\347\257\207/08-\351\222\251\345\255\220\347\263\273\347\273\237-Agent\347\232\204\347\224\237\345\221\275\345\221\250\346\234\237\346\211\251\345\261\225\347\202\271.md" @@ -3,7 +3,7 @@ > **学习目标:** 阅读本章后,你将能够: > > - 理解五种钩子类型的设计哲学、能力边界和适用场景 -> - 掌握 26 个生命周期事件的触发时机、输入结构和退出码语义 +> - 掌握 22 个生命周期事件的触发时机、输入结构和退出码语义 > - 设计结构化钩子响应,利用 `decision`、`updatedInput`、`additionalContext` 等字段实现精细控制 > - 理解多层安全模型:全局禁用、仅托管钩子、工作区信任检查的三层门禁 > - 分析钩子优先级排序规则和冲突解决策略 @@ -210,7 +210,7 @@ flowchart TD ## 8.2 核心生命周期事件 -SDK 核心类型模块定义了完整的 `HOOK_EVENTS` 数组,共 26 个生命周期事件,涵盖工具调用、用户交互、会话管理、子代理、压缩、权限、配置变更等所有关键节点。 +SDK 核心类型模块定义了完整的 `HOOK_EVENTS` 数组;本章按功能分组介绍其中 22 个生命周期事件,涵盖工具调用、用户交互、会话管理、子代理、压缩、权限、配置变更等关键节点。 理解这些事件的最佳方式是把 Agent 的一次完整执行循环想象成一条"流水线"。物料(用户请求)从一端进入,经过多个工位(生命周期事件)的处理,最终从另一端产出成品(Agent 响应)。每个工位都安装了"传感器"(事件),钩子就是连接在传感器上的"控制单元"。 @@ -497,7 +497,7 @@ PreCompact 钩子的处理流程包括:构建钩子输入、执行钩子、提 - **CwdChanged / FileChanged**:工作目录变更和文件变更时触发。适合实现文件系统监控和自动刷新缓存。 - **InstructionsLoaded**:指令文件加载时触发(仅可观测,不支持阻止)。适合审计和日志记录,了解系统加载了哪些指令。 -下表总结了所有 26 个事件的分类、可否阻止和典型用途: +下表总结了上述 22 个事件的分类、可否阻止和典型用途: | 事件名称 | 分类 | 可阻止 | 核心用途 | |---------|------|-------|---------| @@ -1042,7 +1042,7 @@ Command 钩子有默认超时,但如果不显式设置,可能会因为脚本 1. **五种钩子类型**:command(Shell 命令)、prompt(LLM 评估)、agent(Agentic 验证)、http(HTTP 调用)、function(运行时回调),前四种可持久化,最后一种仅在会话内存中存在。选择钩子类型的决策树:需要执行系统命令用 Command,需要智能判断用 Prompt,需要多步验证用 Agent,需要与外部系统集成用 HTTP,需要深度运行时交互用 Function。 -2. **26 个生命周期事件**:覆盖工具调用、用户交互、会话管理、子代理、压缩、权限、配置变更等完整的 Agent 生命周期。每个事件都有明确的触发时机、输入结构和退出码语义。 +2. **22 个生命周期事件**:覆盖工具调用、用户交互、会话管理、子代理、压缩、权限、配置变更等完整的 Agent 生命周期。每个事件都有明确的触发时机、输入结构和退出码语义。 3. **结构化响应协议**:钩子输出是 JSON,包含 `decision`(审批/阻止)、`updatedInput`(修改工具输入)、`additionalContext`(注入上下文)等字段。退出码与 JSON 响应共同控制钩子行为,两者应表达一致的意图。 diff --git "a/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/13-\346\265\201\345\274\217\346\236\266\346\236\204\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md" "b/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/13-\346\265\201\345\274\217\346\236\266\346\236\204\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md" index a243364d..13aefdf8 100644 --- "a/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/13-\346\265\201\345\274\217\346\236\266\346\236\204\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md" +++ "b/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/13-\346\265\201\345\274\217\346\236\266\346\236\204\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md" @@ -349,7 +349,7 @@ QueryEngine 中的 `messageSelector` 使用 `require()` 实现真正的惰性加 工具的 inputSchema/outputSchema 通过 `lazySchema` 实现延迟初始化,避免在模块加载阶段就执行 Zod schema 构建。配合 getter 访问模式,schema 只在首次被实际使用时才构建。 -Zod schema 的构建成本常常被低估。一个包含嵌套对象、可选字段和描述文本的中等复杂度 schema,构建时间约为 0.1-0.3ms。如果 Claude Code 的六十多个工具都在启动时构建 schema,总开销为 6-18ms -- 这对 CLI 工具来说是不可接受的。 +Zod schema 的构建成本常常被低估。一个包含嵌套对象、可选字段和描述文本的中等复杂度 schema,构建时间约为 0.1-0.3ms。如果 Claude Code 的 55 个工具都在启动时构建 schema,总开销为 5.5-16.5ms -- 这对 CLI 工具来说是不可接受的。 `lazySchema` 的实现利用了 JavaScript 的 getter 语义:首次访问时触发构建函数,构建结果被缓存到实例属性上,后续访问直接读取缓存,不再经过 getter。这是一个"一次触发,永久替换"的模式。 @@ -478,7 +478,7 @@ flowchart LR ## 13.5 缓存优化策略 -### 13.5.1 提示缓存共享的三个维度 +### 13.5.1 提示缓存共享的五个维度 Anthropic API 的提示缓存(Prompt Caching)是降低成本和延迟的关键机制。缓存键由五个维度构成:system prompt、tools、model、messages prefix 和 thinking config。 @@ -516,7 +516,7 @@ flowchart TD class WARN warnNode ``` -`CacheSafeParams` 精确地封装了前四个维度:system prompt、user context、system context、tool use context 和 fork context messages。 +`CacheSafeParams` 精确地封装了前四个维度:system prompt(连同 user context 与 system context)、tools 背后的 tool use context、model,以及构成 messages prefix 的 fork context messages。 第五个维度(thinking config)派生自 `toolUseContext.options.thinkingConfig`,但存在一个微妙的陷阱:如果 fork 设置了 `maxOutputTokens`,相关逻辑会据此 clamp `budget_tokens`,导致 thinking config 变化,进而使缓存失效。源码注释明确警告了这一点:如果 fork 使用了 cacheSafeParams 来共享父请求的提示缓存,不同的 budget_tokens 会使缓存失效,因为 thinking config 是缓存键的一部分。 diff --git "a/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/15-\346\236\204\345\273\272\344\275\240\350\207\252\345\267\261\347\232\204Agent-Harness.md" "b/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/15-\346\236\204\345\273\272\344\275\240\350\207\252\345\267\261\347\232\204Agent-Harness.md" index 7c91546a..3e368263 100644 --- "a/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/15-\346\236\204\345\273\272\344\275\240\350\207\252\345\267\261\347\232\204Agent-Harness.md" +++ "b/\347\254\254\345\233\233\351\203\250\345\210\206-\345\267\245\347\250\213\345\256\236\350\267\265\347\257\207/15-\346\236\204\345\273\272\344\275\240\350\207\252\345\267\261\347\232\204Agent-Harness.md" @@ -721,7 +721,7 @@ export class HookRunner { ### 循环依赖的打破策略 -在拥有六十多个工具和数百个模块的代码库中,循环依赖是头号架构杀手。Claude Code 采用了多种策略来打破循环: +在拥有 55 个工具和数百个模块的代码库中,循环依赖是头号架构杀手。Claude Code 采用了多种策略来打破循环: **Lazy Require 模式。** 条件模块通过 `require()` 在运行时加载,结合特性开关,未使用的模块从不被加载。其模式为:使用特性开关判断是否加载,开启时通过 `require()` 获取模块,关闭时返回 null。 diff --git "a/\351\231\204\345\275\225/B-\345\267\245\345\205\267\345\256\214\346\225\264\346\270\205\345\215\225.md" "b/\351\231\204\345\275\225/B-\345\267\245\345\205\267\345\256\214\346\225\264\346\270\205\345\215\225.md" index cf7186cb..18ca2266 100644 --- "a/\351\231\204\345\275\225/B-\345\267\245\345\205\267\345\256\214\346\225\264\346\270\205\345\215\225.md" +++ "b/\351\231\204\345\275\225/B-\345\267\245\345\205\267\345\256\214\346\225\264\346\270\205\345\215\225.md" @@ -1,6 +1,6 @@ # 附录 B:工具完整清单 -本附录列出 Claude Code 架构中注册的全部工具,按功能分类索引。工具是智能体与外部世界交互的基本能力单元,每个工具都通过统一的接口协议注册到工具注册表中,接受对话主循环的调度。 +本附录按 12 个功能类别列出 Claude Code 架构中注册的全部 55 个工具。工具是智能体与外部世界交互的基本能力单元,每个工具都通过统一的接口协议注册到工具注册表中,接受对话主循环的调度。 **权限模型字段含义**: - **readOnly**:工具是否仅执行读取操作,不修改文件系统或外部状态。只读工具通常权限约束更宽松,在 plan 模式下也可使用