Add mermaid diagrams to all lectures, PDF build pipeline, and README screenshots

- Add 1-2 mermaid diagrams per lecture (EN + ZH, 24 files total) illustrating
  core concepts: five-layer failure model, harness subsystems, knowledge visibility,
  instruction architecture, session continuity, initialization lifecycle, WIP=1
  workflow, feature state machine, termination checks, test pyramid, observability
  layers, and clean state dimensions
- Add PDF build pipeline: scripts/build-course-pdfs.ts and npm scripts
- Add README screenshot capture script and workflow
- Add GitHub Actions workflow for automated PDF releases
- Update README/README-CN with screenshots and PDF documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanbuphy
2026-04-02 17:45:03 +08:00
parent 1ebca824ac
commit 67dfa5937e
40 changed files with 1402 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Build course PDFs and publish release assets
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to create or update
required: true
type: string
release_name:
description: Release title
required: true
type: string
prerelease:
description: Mark release as prerelease
required: false
default: false
type: boolean
release:
types:
- published
permissions:
contents: write
jobs:
build-pdfs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build course PDFs
run: npm run pdf:build
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4
with:
name: course-pdfs
path: artifacts/pdfs
- name: Publish PDFs to existing release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
files: artifacts/pdfs/*.pdf
- name: Create or update release with PDFs
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.release_name }}
prerelease: ${{ inputs.prerelease }}
generate_release_notes: true
files: artifacts/pdfs/*.pdf
+1
View File
@@ -26,3 +26,4 @@ pnpm-debug.log*
docs/.vitepress/cache/
docs/.vitepress/dist/
*.tsbuildinfo
artifacts/
+29
View File
@@ -13,6 +13,35 @@ Learn Harness Engineering 是一门专注于 AI 编程智能体工程化落地
---
## 页面截图
### 中文
<p>
<img src="./docs/public/screenshots/readme/zh-home.png" alt="中文首页预览" width="32%" />
<img src="./docs/public/screenshots/readme/zh-lecture-01.png" alt="中文讲义预览" width="32%" />
<img src="./docs/public/screenshots/readme/zh-resources.png" alt="中文资料库预览" width="32%" />
</p>
### English
<p>
<img src="./docs/public/screenshots/readme/en-home.png" alt="English homepage preview" width="32%" />
<img src="./docs/public/screenshots/readme/en-lecture-01.png" alt="English lecture preview" width="32%" />
<img src="./docs/public/screenshots/readme/en-resources.png" alt="English resources preview" width="32%" />
</p>
## PDF 构建与 Release 流水线
仓库里现在已经补上了课程 PDF 的构建链路。
- 本地执行 `npm run pdf:build`,会生成中英文两份课程 PDF。
- 输出目录是 `artifacts/pdfs/`
- 如果需要刷新 README 里的截图,执行 `npm run screenshots:readme`
- GitHub Actions 工作流 [`release-course-pdfs.yml`](./.github/workflows/release-course-pdfs.yml) 可以自动构建 PDF并把产物上传到 GitHub Release。
---
## 模型很强,但 Harness 让它靠谱
有一个很多人交过学费才明白的事实:**世界上最强的模型,如果没有一个合适的工作环境,依然会在真实工程任务中翻车。**
+29
View File
@@ -13,6 +13,35 @@ Learn Harness Engineering is a course dedicated to the engineering of AI coding
---
## Visual Preview
### English
<p>
<img src="./docs/public/screenshots/readme/en-home.png" alt="English homepage preview" width="32%" />
<img src="./docs/public/screenshots/readme/en-lecture-01.png" alt="English lecture preview" width="32%" />
<img src="./docs/public/screenshots/readme/en-resources.png" alt="English resources preview" width="32%" />
</p>
### 中文
<p>
<img src="./docs/public/screenshots/readme/zh-home.png" alt="中文首页预览" width="32%" />
<img src="./docs/public/screenshots/readme/zh-lecture-01.png" alt="中文讲义预览" width="32%" />
<img src="./docs/public/screenshots/readme/zh-resources.png" alt="中文资料库预览" width="32%" />
</p>
## PDF Coursebooks
The repository now includes a PDF build pipeline for the course content.
- Run `npm run pdf:build` to generate English and Chinese PDFs locally.
- Output files are written to `artifacts/pdfs/`.
- Run `npm run screenshots:readme` if you want to refresh the README preview images.
- GitHub Actions workflow [`release-course-pdfs.yml`](./.github/workflows/release-course-pdfs.yml) can build the PDFs and publish them to GitHub Releases.
---
## The Model Is Smart, The Harness Makes It Reliable
There's a hard truth most people learn the hard way: **the strongest model in the world will still fail on real engineering tasks if you don't build a proper environment around it.**
@@ -20,6 +20,31 @@ This isn't because the model isn't smart enough. It's because the working enviro
- **Diagnostic Loop**: Execute → observe failure → attribute to a specific harness layer → fix that layer → re-execute. This is the core methodology of harness engineering.
- **Definition of Done**: A set of machine-verifiable conditions — tests pass, lint is clean, type checks pass. Without an explicit definition of done, the agent will invent its own.
## Failure Model Overview
```mermaid
graph LR
subgraph "Five Failure Layers"
direction TB
L1["① Task Specification"]
L2["② Context Provision"]
L3["③ Execution Environment"]
L4["④ Verification Feedback"]
L5["⑤ State Management"]
end
Agent -->|"encounters"| L1
L1 --> L2 --> L3 --> L4 --> L5
L5 -->|"signals defect"| Fix["Fix layer → Re-run"]
Fix -->|"diagnostic loop"| Agent
```
```mermaid
graph LR
Bare["Bare Run<br/>20 min / $9<br/>Core features broken"] -->|"Add harness"| Full["Full Harness<br/>6 hr / $200<br/>Playable app"]
Bare -.->|"Same model"| Full
```
## Why This Happens
Let's start with data. As of late 2025, the strongest coding agents on SWE-bench Verified achieve roughly 50-60%. And that's on carefully selected tasks with clear issue descriptions and existing test cases. Move to a real daily development scenario — vague requirements, no existing tests, implicit business rules scattered everywhere — and that number only goes down.
@@ -20,6 +20,21 @@ This lecture gives you a precise, actionable definition. Not an academic abstrac
- **Feedback Latency**: The time between an agent's action and the feedback it receives. Compilation errors are second-level feedback, test suites are minute-level, "bug in production" is day-level. Faster feedback means faster correction.
- **Isometric Model Control**: Keep the model fixed, systematically vary harness subsystems, measure each one's marginal contribution. This is the correct way to quantify a harness's value.
## The Five-Tuple Harness Model
```mermaid
graph TB
Agent["AI Agent"] --> I["📋 Instructions<br/><i>AGENTS.md, CLAUDE.md</i>"]
Agent --> T["🔧 Tools<br/><i>Shell, file ops, tests</i>"]
Agent --> E["🖥️ Environment<br/><i>Dependencies, runtime</i>"]
Agent --> S["💾 State<br/><i>Progress files, git</i>"]
Agent --> F["✅ Feedback<br/><i>Test results, lint, build</i>"]
I ~~~ T ~~~ E ~~~ S ~~~ F
F -->|"highest ROI"| Star["⭐ Start here"]
```
## Why This Happens
Here's an analogy. Imagine you're a newly hired engineer dropped into a project with zero documentation. No README, no comments in the code, nobody tells you how to run tests, CI config is buried somewhere. Can you write good code? Maybe, but you'll spend enormous time on "figuring out what this project is about" rather than "solving the problem."
@@ -20,6 +20,39 @@ This lecture explains why you must put everything an agent needs to know into th
- **Knowledge Decay Rate**: The proportion of knowledge entries that become stale per unit of time. Documentation going out of sync with code is the biggest enemy.
- **ACID for Agent State**: Applying database transaction principles (Atomicity, Consistency, Isolation, Durability) to agent state management.
## Knowledge Visibility
```mermaid
graph LR
subgraph "Visible to Agent"
Repo["Repository Files<br/>AGENTS.md, code, tests"]
end
subgraph "Invisible to Agent"
Slack["Slack History"]
Confluence["Confluence Docs"]
Heads["Senior Engineers' Heads"]
Jira["Jira Tickets"]
end
Repo -->|"accessible"| Agent["🤖 Agent"]
Slack -.->|"inaccessible"| Agent
Confluence -.->|"inaccessible"| Agent
Heads -.->|"inaccessible"| Agent
Jira -.->|"inaccessible"| Agent
```
```mermaid
graph TB
subgraph "Cold-Start Test"
Q1["What is this system?"]
Q2["How is it organized?"]
Q3["How do I run it?"]
Q4["How do I verify it?"]
Q5["Where are we now?"]
end
Q1 & Q2 & Q3 & Q4 & Q5 -->|"all must be<br/>answerable from repo"| Pass["✅ Harness Ready"]
```
## Why This Happens
Think about what an agent's inputs actually are: system prompts and task descriptions, file contents from the repository, and tool execution output. That's it. Your Slack history, Jira tickets, Confluence pages, and that architecture decision you discussed with a colleague over coffee on Friday afternoon — the agent can't see any of it.
@@ -20,6 +20,31 @@ This is the "giant instruction file" trap. This lecture explains why "more infor
- **Progressive Disclosure**: Give overview information first, detailed information when needed. Good harness design is like good UI design — don't dump all options on the user at once.
- **Priority Ambiguity**: When all instructions appear in the same format and location, the agent can't distinguish non-negotiable hard constraints from suggestive soft guidelines.
## Instruction Architecture
```mermaid
graph TB
subgraph "Three-Layer Structure"
L1["<b>Layer 1: Routing File</b><br/>AGENTS.md (50200 lines)<br/>Overview + hard constraints + links"]
L2a["<b>Layer 2a</b><br/>api-patterns.md"]
L2b["<b>Layer 2b</b><br/>database-rules.md"]
L2c["<b>Layer 2c</b><br/>testing-standards.md"]
L3["<b>Layer 3: Inline</b><br/>Type defs, interface comments"]
end
L1 -->|"link"| L2a
L1 -->|"link"| L2b
L1 -->|"link"| L2c
L2a & L2b & L2c -->|"reference"| L3
style L1 fill:#D95C41,color:#fff
```
```mermaid
graph LR
Top["Top of file<br/>✅ High recall"] --- Mid["Middle of file<br/>❌ Lost in the middle"] --- Bot["Bottom of file<br/>✅ High recall"]
```
## Why This Happens
The most common vicious cycle goes like this: the agent makes a mistake → you say "add a rule to prevent this" → add it to AGENTS.md → it works temporarily → agent makes a different mistake → add another rule → repeat → file bloats out of control.
@@ -20,6 +20,30 @@ This is one of the most painful problems with AI coding agents: cross-session co
- **Compaction vs Reset**: Compaction summarizes context within the same session (keeps "what," may lose "why"); reset opens a new session rebuilding from persisted state (clean but depends on artifact completeness).
- **Context Anxiety**: A phenomenon observed by Anthropic — agents exhibit premature convergence behavior when approaching perceived context limits, ending tasks early to avoid information loss. It's an irrational resource anxiety.
## Session Continuity Flow
```mermaid
graph LR
subgraph "Session N"
Work1["Work on task"] --> Update1["Update PROGRESS.md<br/>Update DECISIONS.md"]
Update1 --> Commit1["Git commit checkpoint"]
end
subgraph "Session N+1"
Read1["Read PROGRESS.md<br/>Read DECISIONS.md"] --> Resume["Resume from<br/>Next Steps"]
Resume --> Work2["Continue work"]
end
Commit1 -->|"handoff"| Read1
```
```mermaid
graph TB
subgraph "Information Loss"
Full["Full Context<br/>What + Why + How"] -->|"compaction"| Compact["Compacted<br/>What ✓ · Why ✗"]
Full -->|"new session"| Reset["Reset<br/>Rebuild from artifacts"]
end
```
## Why This Happens
Context windows are finite. This isn't solvable by model upgrades — even if window sizes grow to 1M tokens, complex tasks will still exhaust them. Because agents aren't just generating code; they're understanding codebases, tracking their own decision history, processing tool output, and maintaining conversation context. All this information grows faster than window expansion.
@@ -20,6 +20,29 @@ The better approach: before letting the agent start working, use a separate phas
- **Time to First Verification**: The time from project start until the first feature point passes verification. This is the core metric for measuring initialization efficiency.
- **Downstream Usability**: The best measure of initialization quality — the proportion of subsequent sessions that can successfully execute tasks without relying on implicit knowledge.
## Initialization Lifecycle
```mermaid
graph LR
subgraph "Initialization Phase"
I1["Runnable environment<br/><i>deps installed</i>"] --> I2["Test framework<br/><i>example test passes</i>"]
I2 --> I3["Bootstrap contract<br/><i>start/test/verify docs</i>"]
I3 --> I4["Task breakdown<br/><i>ordered feature list</i>"]
I4 --> I5["Git checkpoint<br/><i>clean commit</i>"]
end
subgraph "Implementation Phase"
I5 --> P1["Session 2: Feature 1"]
P1 --> P2["Session 3: Feature 2"]
P2 --> P3["Session N: Feature N"]
end
style I1 fill:#D95C41,color:#fff
style I2 fill:#D95C41,color:#fff
style I3 fill:#D95C41,color:#fff
style I4 fill:#D95C41,color:#fff
style I5 fill:#D95C41,color:#fff
```
## Why This Happens
Initialization and implementation have fundamentally different optimization targets. The implementation phase optimizes for: maximizing the quantity and quality of verified features. The initialization phase optimizes for: maximizing the reliability and efficiency of all subsequent implementation.
@@ -20,6 +20,26 @@ Anthropic's "Effective harnesses for long-running agents" engineering blog post
- **Scope Surface**: A DAG structure where each node is a work unit and edges are dependencies. States are limited to four: not_started, active, blocked, passing.
- **Completion Pressure**: The constraining force the harness exerts through WIP limits and completion evidence requirements, forcing the agent to finish the current task before starting a new one.
## WIP=1 Workflow
```mermaid
graph LR
subgraph "WIP = 1 (Correct)"
Pick1["Pick feature"] --> Do1["Implement"] --> Verify1["Verify E2E"]
Verify1 -->|"pass"| Commit1["Commit"]
Commit1 --> Next1["Next feature"]
Verify1 -->|"fail"| Do1
end
```
```mermaid
graph LR
subgraph "Unconstrained (Wrong)"
Pick2["Activate 5 features"] --> Do2["Work on all"]
Do2 --> Result2["800 lines, 12 files<br/>20% pass rate ❌"]
end
```
## Why This Happens
### Agents Are Born Wanting to "Do a Little Extra"
@@ -20,6 +20,31 @@ Feature lists are not planning documents for humans to read. They are the core d
- **Single source of truth**: All information about "what needs to be done" in a project must be derived from one feature list. No contradictions between the feature list and conversation history.
- **Back-pressure**: The number of features that haven't passed yet is the pressure the harness exerts on the agent. Zero pressure = project complete.
## Feature State Machine
```mermaid
stateDiagram-v2
[*] --> not_started
not_started --> active: Agent picks task
active --> passing: Verification command passes
active --> blocked: External dependency
blocked --> active: Dependency resolved
passing --> [*]
note right of passing
Irreversible: once passing,
cannot go back
end note
```
```mermaid
graph TB
FL["📋 Feature List<br/><i>Single source of truth</i>"] --> Scheduler["Scheduler<br/><i>picks next task</i>"]
FL --> Verifier["Verifier<br/><i>runs check commands</i>"]
FL --> Handoff["Handoff Reporter<br/><i>generates summaries</i>"]
FL --> Progress["Progress Tracker<br/><i>tallies state distribution</i>"]
```
## Why This Happens
### Agents Don't Come with a Built-in "Done" Definition
@@ -20,6 +20,32 @@ This isn't a random event. Guo et al.'s classic 2017 ICML paper proved that **mo
- **Runtime feedback signals**: Logs, process states, health checks from program execution. These are the harness's objective basis for judging completion quality — not optional debugging tools.
- **Completion priority constraint**: Verify functional correctness first, then performance, then style. No refactoring allowed until core functionality is verified.
## Three-Layer Termination Check
```mermaid
graph LR
subgraph "Verification Gates"
L1["Layer 1<br/>Syntax & Static Analysis<br/><i>lint, type check</i>"]
L2["Layer 2<br/>Runtime Behavior<br/><i>unit & integration tests</i>"]
L3["Layer 3<br/>System Validation<br/><i>E2E, user scenarios</i>"]
end
L1 -->|"must pass"| L2
L2 -->|"must pass"| L3
L3 -->|"all pass"| Done["✅ Done"]
style L1 fill:#F4F3EE
style L2 fill:#E8E7E2
style L3 fill:#D95C41,color:#fff
```
```mermaid
graph LR
Agent["Agent: 'I'm done!'"] -->|"confidence"| High["High Confidence"]
Reality["Actual Quality"] -->|"measured"| Low["Low Quality"]
High -.->|"calibration gap"| Low
```
## Why This Happens
### The Four-Step Slippery Slope of Agent Completion
@@ -19,6 +19,31 @@ Google's test pyramid tells us: lots of unit tests form the base, but if you sto
- **Review feedback promotion**: Transform recurring code review comments into automated tests. Each newly captured defect category adds a permanent defense line — the harness automatically gets stronger over time.
- **Agent-oriented error messages**: Failure messages don't just say "what went wrong" — they tell the agent exactly how to fix it. This turns test failures into self-correcting feedback loops.
## Test Pyramid & Review Feedback Loop
```mermaid
graph TB
subgraph "Test Adequacy Gradient"
E2E["End-to-End Tests<br/><i>catches component boundary defects</i>"]
Int["Integration Tests"]
Unit["Unit Tests<br/><i>fast but isolated</i>"]
end
Unit --> Int --> E2E
style Unit fill:#F4F3EE
style Int fill:#E0DFD9
style E2E fill:#D95C41,color:#fff
```
```mermaid
graph LR
Review["Code Review<br/>recurring feedback"] --> Pattern["Identify pattern"]
Pattern --> Rule["Create executable check<br/>+ agent-oriented error msg"]
Rule --> Harness["Add to harness"]
Harness -->|"prevents class"| Agent["🤖 Agent"]
```
## Why This Happens
### Unit Tests' Blind Spots
@@ -20,6 +20,30 @@ This isn't about the agent lacking capability. It's about your harness not provi
- **Evaluator rubric**: Transforms quality evaluation from subjective judgment into evidence-based structured scoring. Makes different evaluators produce similar results for the same output.
- **Layered observability**: System-layer and process-layer observability designed simultaneously and reinforcing each other. Runtime signals explain behavior; process artifacts explain intent.
## Layered Observability
```mermaid
graph TB
subgraph "Process Layer"
SC["Sprint Contract<br/><i>scope + standards + exclusions</i>"]
ER["Evaluator Rubric<br/><i>structured scoring</i>"]
end
subgraph "System Layer"
Logs["Runtime Logs"]
Traces["Request Traces"]
Health["Health Checks"]
end
SC -->|"guides"| Gen["Generator"]
Gen -->|"produces"| Runtime["Runtime Behavior"]
Runtime --> Logs & Traces & Health
Logs & Traces & Health -->|"evidence"| ER
ER -->|"feedback"| Gen
style SC fill:#D95C41,color:#fff
style ER fill:#D95C41,color:#fff
```
## Why This Happens
### The Real Cost of Missing Observability
@@ -20,6 +20,38 @@ Both OpenAI and Anthropic state clearly: **long-term reliability depends on oper
- **Harness simplification**: As model capabilities improve, periodically remove harness components that are no longer necessary. A constraint essential today may be unnecessary overhead in three months.
- **Idempotent cleanup**: Cleanup operations produce the same result regardless of how many times they run. Ensures cleanup remains safe even in failure-retry scenarios.
## Five Dimensions of Clean State
```mermaid
graph TB
subgraph "Session Exit Checklist"
B["✅ Build passes"]
T["✅ Tests pass"]
P["✅ Progress recorded"]
A["✅ No stale artifacts"]
S["✅ Startup path available"]
end
B & T & P & A & S -->|"all required"| Clean["🧹 Clean State"]
Clean -->|"enables"| Next["Next session<br/>immediate productivity"]
```
```mermaid
graph LR
subgraph "Entropy Over 12 Weeks"
W1["Week 1<br/>100% build, 100% tests"] --> W4["Week 4<br/>95% / 92%"]
W4 --> W8["Week 8<br/>82% / 78%"]
W8 --> W12["Week 12<br/>68% / 61%"]
end
subgraph "With Cleanup Strategy"
C1["Week 1<br/>100% / 100%"] --> C12["Week 12<br/>97% / 95%"]
end
style W12 fill:#D95C41,color:#fff
style C12 fill:#4CAF50,color:#fff
```
## Why This Happens
### Entropy Growth Is the Default State
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -20,6 +20,31 @@
- **诊断循环**:执行 → 观察失败 → 定位到 harness 的哪一层出了问题 → 修补那一层 → 重新执行。这是 harness 工程的核心方法论。
- **完成定义Definition of Done**一组可以用命令验证的条件——测试通过、lint 没报错、类型检查通过。没有显式的完成定义agent 就会自己编一个。
## 五层失败模型
```mermaid
graph LR
subgraph "五层失败模型"
direction TB
L1["① 任务规范层"]
L2["② 上下文供给层"]
L3["③ 执行环境层"]
L4["④ 验证反馈层"]
L5["⑤ 状态管理层"]
end
Agent["🤖 Agent"] -->|"遇到"| L1
L1 --> L2 --> L3 --> L4 --> L5
L5 -->|"信号:缺陷"| Fix["修补对应层 → 重新执行"]
Fix -->|"诊断循环"| Agent
```
```mermaid
graph LR
Bare["裸跑模式<br/>20 分钟 / $9<br/>核心功能跑不起来"] -->|"加上 harness"| Full["完整 harness<br/>6 小时 / $200<br/>应用可以正常使用"]
Bare -.->|"同一个模型"| Full
```
## 为什么会这样
先看一组数据。截至 2025 年底,最强的 coding agent 在 SWE-bench Verified 上的通过率大约在 50-60% 左右。这还是精心挑选过的、有明确 issue 描述和测试用例的任务。换到真实的日常开发场景——需求模糊、没有现成测试、隐含的业务规则散落在各处——这个数字只会更低。
@@ -19,6 +19,21 @@
- **约束而非微操**:好的 harness 用可执行的规则来约束 agent而不是在指令里逐条叮嘱。OpenAI 说"执行不变量,不要微管实现"Anthropic 发现 agent 会自信地夸赞自己的工作,解决方案是把"干活的人"和"检查的人"分开。
- **逐个组件拆除法**:想量化 harness 各组件的价值就逐个移除看哪个移除后性能下降最多。Anthropic 用这个方法发现:随着模型变强,某些组件不再关键,但总会有新的关键组件出现。
## Harness 五子系统模型
```mermaid
graph TB
Agent["🤖 AI Agent"] --> I["📋 指令<br/><i>AGENTS.md, CLAUDE.md</i>"]
Agent --> T["🔧 工具<br/><i>Shell, 文件操作, 测试</i>"]
Agent --> E["🖥️ 环境<br/><i>依赖, 运行时</i>"]
Agent --> S["💾 状态<br/><i>进度文件, git</i>"]
Agent --> F["✅ 反馈<br/><i>测试结果, lint, 构建</i>"]
I ~~~ T ~~~ E ~~~ S ~~~ F
F -->|"最高投入产出比"| Star["⭐ 从这里开始"]
```
## 为什么会这样
让我们用一个类比来说明。想象你是一个刚入职的工程师,被丢进一个没有任何文档的项目里。没有 README代码里没有注释没有人告诉你怎么跑测试CI 配置文件藏在某个角落里。你能写出好代码吗?也许能,但你会花大量时间在"搞清楚这个项目是怎么回事"上,而不是在"解决问题"上。
@@ -20,6 +20,39 @@
- **知识衰减率**:仓库中单位时间内变得过时的知识条目比例。文档和代码脱节是最大的敌人。
- **ACID 类比**:把数据库的事务管理原则(原子性、一致性、隔离性、持久性)用到 agent 的状态管理上。
## 知识可见性
```mermaid
graph LR
subgraph "Agent 可见的"
Repo["仓库文件<br/>AGENTS.md, 代码, 测试"]
end
subgraph "Agent 看不到的"
Slack["Slack 聊天记录"]
Confluence["Confluence 文档"]
Heads["资深工程师的脑子"]
Jira["Jira 工单"]
end
Repo -->|"可访问"| Agent["🤖 Agent"]
Slack -.->|"不可访问"| Agent
Confluence -.->|"不可访问"| Agent
Heads -.->|"不可访问"| Agent
Jira -.->|"不可访问"| Agent
```
```mermaid
graph TB
subgraph "冷启动测试"
Q1["这是什么系统?"]
Q2["怎么组织的?"]
Q3["怎么跑?"]
Q4["怎么验证?"]
Q5["现在做到哪了?"]
end
Q1 & Q2 & Q3 & Q4 & Q5 -->|"全部能从仓库回答"| Pass["✅ Harness 就绪"]
```
## 为什么会这样
想想 agent 的输入都有什么:系统提示和任务描述、仓库里的文件内容、以及工具执行的输出。就这三样。你的 Slack 历史、Jira 工单、Confluence 页面、和周五下午跟同事在茶水间聊的架构决定——agent 全都看不到。
@@ -20,6 +20,31 @@
- **渐进式披露**:先给概要信息,需要的时候再给详细信息。好的 harness 设计和好的 UI 设计一样,不把所有选项一次性砸到用户脸上。
- **优先级模糊度**当所有指令以相同格式和位置呈现时agent 分不清哪些是不可违反的硬约束,哪些是建议性的软约束。
## 指令文件架构
```mermaid
graph TB
subgraph "三层结构"
L1["<b>第一层:路由文件</b><br/>AGENTS.md (50200 行)<br/>概览 + 硬约束 + 链接"]
L2a["<b>第二层 a</b><br/>api-patterns.md"]
L2b["<b>第二层 b</b><br/>database-rules.md"]
L2c["<b>第二层 c</b><br/>testing-standards.md"]
L3["<b>第三层:内联注释</b><br/>类型定义, 接口注释"]
end
L1 -->|"链接"| L2a
L1 -->|"链接"| L2b
L1 -->|"链接"| L2c
L2a & L2b & L2c -->|"引用"| L3
style L1 fill:#D95C41,color:#fff
```
```mermaid
graph LR
Top["文件顶部<br/>✅ 高召回率"] --- Mid["文件中间<br/>❌ 中间迷失效应"] --- Bot["文件底部<br/>✅ 高召回率"]
```
## 为什么会这样
最常见的恶性循环是这样的agent 犯了个错 → 你说"加条规则防止这个" → 加到 AGENTS.md → 暂时管用 → agent 又犯了另一个错 → 再加一条 → 重复 → 文件膨胀到不可控。
@@ -20,6 +20,30 @@
- **压缩 vs 重置**:压缩是在同一个会话里把上下文摘要化(保留"是什么",可能丢了"为什么");重置是开新会话从持久化状态重建(干净但依赖工件完备性)。
- **上下文焦虑**Anthropic 观察到的一个现象——agent 在接近上下文限制时表现异常,过早结束任务以避免信息丢失。这是一种非理性的资源焦虑。
## 会话连续性流程
```mermaid
graph LR
subgraph "会话 N"
Work1["执行任务"] --> Update1["更新 PROGRESS.md<br/>更新 DECISIONS.md"]
Update1 --> Commit1["Git 提交检查点"]
end
subgraph "会话 N+1"
Read1["读取 PROGRESS.md<br/>读取 DECISIONS.md"] --> Resume["从下一步继续"]
Resume --> Work2["继续工作"]
end
Commit1 -->|"交接"| Read1
```
```mermaid
graph TB
subgraph "信息损失"
Full["完整上下文<br/>是什么 + 为什么 + 怎么做"] -->|"压缩"| Compact["压缩后<br/>是什么 ✓ · 为什么 ✗"]
Full -->|"新会话"| Reset["重置<br/>从工件重建"]
end
```
## 为什么会这样
上下文窗口是有限的。这不是一个可以通过模型升级解决的问题——即使窗口大小增长到 1M tokens复杂任务依然会用完。因为 agent 不只是在生成代码,它还要理解代码库、跟踪自己的决策历史、处理工具输出、维护对话上下文。这些信息加起来增长得比窗口扩容快得多。
@@ -20,6 +20,29 @@
- **首次验证时间**:从项目开始到第一个功能点通过验证的时间。这是衡量初始化效率的核心指标。
- **下游可用性**:初始化质量的最佳衡量标准——后续会话不需要依赖隐式知识就能成功执行任务的比例。
## 初始化生命周期
```mermaid
graph LR
subgraph "初始化阶段"
I1["可运行环境<br/><i>依赖已安装</i>"] --> I2["测试框架<br/><i>示例测试通过</i>"]
I2 --> I3["自举契约<br/><i>启动/测试/验证文档</i>"]
I3 --> I4["任务拆分<br/><i>有序的功能清单</i>"]
I4 --> I5["Git 检查点<br/><i>干净提交</i>"]
end
subgraph "实现阶段"
I5 --> P1["会话 2功能 1"]
P1 --> P2["会话 3功能 2"]
P2 --> P3["会话 N功能 N"]
end
style I1 fill:#D95C41,color:#fff
style I2 fill:#D95C41,color:#fff
style I3 fill:#D95C41,color:#fff
style I4 fill:#D95C41,color:#fff
style I5 fill:#D95C41,color:#fff
```
## 为什么会这样
初始化和实现的优化目标完全不同。实现阶段的目标是:最大化已验证功能的数量和质量。初始化阶段的目标是:最大化后续所有实现的可靠性和效率。
@@ -20,6 +20,26 @@ Anthropic 在 "Effective harnesses for long-running agents" 工程博客中明
- **范围表面Scope Surface**:一个 DAG 结构,每个节点是一个工作单元,边是依赖关系。状态只有四种:未开始、进行中、阻塞、已通过。
- **完成压力Completion Pressure**harness 通过 WIP 限制和完成证据要求共同产生的约束力,迫使 agent 先完成当前任务再开始新任务。
## WIP=1 工作流
```mermaid
graph LR
subgraph "WIP = 1正确做法"
Pick1["选取功能"] --> Do1["实现"] --> Verify1["端到端验证"]
Verify1 -->|"通过"| Commit1["提交"]
Commit1 --> Next1["下一个功能"]
Verify1 -->|"失败"| Do1
end
```
```mermaid
graph LR
subgraph "无约束(错误做法)"
Pick2["同时激活 5 个功能"] --> Do2["全部做一点"]
Do2 --> Result2["800 行12 个文件<br/>20% 通过率 ❌"]
end
```
## 为什么会这样
### Agent 天生就想"多做一点"
@@ -20,6 +20,31 @@
- **单一权威来源**:项目里关于"该做什么"的所有信息,必须从一个功能清单派生。不能出现功能清单和对话记录矛盾的情况。
- **反向压力**:还没通过的功能项数量就是 harness 对 agent 施加的压力。压力归零 = 项目完成。
## 功能状态机
```mermaid
stateDiagram-v2
[*] --> not_started
not_started --> active: Agent 选取任务
active --> passing: 验证命令通过
active --> blocked: 外部依赖阻塞
blocked --> active: 依赖已解决
passing --> [*]
note right of passing
不可逆:一旦 passing
不能回退
end note
```
```mermaid
graph TB
FL["📋 功能清单<br/><i>单一权威来源</i>"] --> Scheduler["调度器<br/><i>选取下一个任务</i>"]
FL --> Verifier["验证器<br/><i>执行验证命令</i>"]
FL --> Handoff["交接报告器<br/><i>生成摘要</i>"]
FL --> Progress["进度追踪器<br/><i>统计状态分布</i>"]
```
## 为什么会这样
### Agent 没有自带"完成定义"
@@ -20,6 +20,32 @@
- **运行时反馈信号**:来自程序执行的日志、进程状态、健康检查。这是 harness 判定完成质量的客观基础,不是可选的调试工具。
- **完成优先级约束**:先验证功能正确性,再处理性能,最后管风格。核心功能没验证通过之前,不许做重构。
## 三层终止检查
```mermaid
graph LR
subgraph "验证闸门"
L1["第一层<br/>语法与静态分析<br/><i>lint, 类型检查</i>"]
L2["第二层<br/>运行时行为<br/><i>单元与集成测试</i>"]
L3["第三层<br/>系统级确认<br/><i>端到端, 用户场景</i>"]
end
L1 -->|"必须通过"| L2
L2 -->|"必须通过"| L3
L3 -->|"全部通过"| Done["✅ 完成"]
style L1 fill:#F4F3EE
style L2 fill:#E8E7E2
style L3 fill:#D95C41,color:#fff
```
```mermaid
graph LR
Agent["Agent: '我做完了!'"] -->|"置信度"| High["高置信度"]
Reality["实际质量"] -->|"实测"| Low["低质量"]
High -.->|"校准偏差"| Low
```
## 为什么会这样
### Agent 完成判定的四步滑坡
@@ -19,6 +19,31 @@ Google 的测试金字塔告诉我们:大量单元测试是基础,但如果
- **审查反馈提升**把重复出现的代码审查意见转化为自动化测试。每次发现重复问题就加一条规则harness 会自动变强。
- **面向 agent 的错误消息**:失败信息不只是说"出了什么问题",还要告诉 agent 具体怎么修。这把测试失败变成自我修正的反馈循环。
## 测试金字塔与审查反馈提升
```mermaid
graph TB
subgraph "测试充分性梯度"
E2E["端到端测试<br/><i>捕获组件边界缺陷</i>"]
Int["集成测试"]
Unit["单元测试<br/><i>快速但隔离</i>"]
end
Unit --> Int --> E2E
style Unit fill:#F4F3EE
style Int fill:#E0DFD9
style E2E fill:#D95C41,color:#fff
```
```mermaid
graph LR
Review["代码审查<br/>重复性反馈"] --> Pattern["识别模式"]
Pattern --> Rule["创建可执行检查<br/>+ 面向 agent 的错误消息"]
Rule --> Harness["加入 harness"]
Harness -->|"预防同类问题"| Agent["🤖 Agent"]
```
## 为什么会这样
### 单元测试的盲区
@@ -20,6 +20,30 @@
- **评估评分标准**:把质量评估从主观判断变成基于证据的结构化评分。使不同评估者对同一输出产生相似结论。
- **双层可观测性**:系统层和过程层同时设计、相互增强。运行时信号解释行为,过程工件解释意图。
## 双层可观测性
```mermaid
graph TB
subgraph "过程层"
SC["冲刺合同<br/><i>范围 + 标准 + 排除项</i>"]
ER["评估评分标准<br/><i>结构化评分</i>"]
end
subgraph "系统层"
Logs["运行时日志"]
Traces["请求追踪"]
Health["健康检查"]
end
SC -->|"指导"| Gen["生成器"]
Gen -->|"产出"| Runtime["运行时行为"]
Runtime --> Logs & Traces & Health
Logs & Traces & Health -->|"证据"| ER
ER -->|"反馈"| Gen
style SC fill:#D95C41,color:#fff
style ER fill:#D95C41,color:#fff
```
## 为什么会这样
### 可观测性缺失的真实代价
@@ -20,6 +20,38 @@ OpenAI 和 Anthropic 都明确指出:**长期可靠性取决于操作纪律,
- **harness 简化**:随着模型能力提升,定期移除不再必要的 harness 组件。今天必要的约束,三个月后可能是多余的开销。
- **幂等清理**:清理操作无论执行多少次都产生相同结果。确保清理在失败重试场景中仍然安全。
## 清洁状态的五个维度
```mermaid
graph TB
subgraph "会话退出检查清单"
B["✅ 构建通过"]
T["✅ 测试通过"]
P["✅ 进度已记录"]
A["✅ 无过时工件"]
S["✅ 启动路径可用"]
end
B & T & P & A & S -->|"缺一不可"| Clean["🧹 清洁状态"]
Clean -->|"保障"| Next["下一会话<br/>立即进入工作状态"]
```
```mermaid
graph LR
subgraph "12 周后(无清理)"
W1["第 1 周<br/>100% 构建, 100% 测试"] --> W4["第 4 周<br/>95% / 92%"]
W4 --> W8["第 8 周<br/>82% / 78%"]
W8 --> W12["第 12 周<br/>68% / 61%"]
end
subgraph "12 周后(有清理策略)"
C1["第 1 周<br/>100% / 100%"] --> C12["第 12 周<br/>97% / 95%"]
end
style W12 fill:#D95C41,color:#fff
style C12 fill:#4CAF50,color:#fff
```
## 为什么会这样
### 熵增是默认状态
+6
View File
@@ -0,0 +1,6 @@
const https = require('https');
https.get('https://raw.githubusercontent.com/anthropics/anthropic-cookbook/main/images/logo.svg', (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => console.log(data));
});
+96
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"devDependencies": {
"mermaid": "^11.14.0",
"pdf-lib": "^1.17.1",
"playwright": "^1.59.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitepress": "^1.6.4",
@@ -952,6 +954,26 @@
"langium": "^4.0.0"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
@@ -3317,6 +3339,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz",
@@ -3331,6 +3360,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmmirror.com/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -3357,6 +3399,53 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -3678,6 +3767,13 @@
"node": ">=6.10"
}
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
+6 -1
View File
@@ -10,10 +10,15 @@
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"lecture:run": "tsx"
"lecture:run": "tsx",
"screenshots:readme": "node --import tsx scripts/capture-readme-screenshots.ts",
"pdf:export": "node --import tsx scripts/build-course-pdfs.ts",
"pdf:build": "npm run docs:build && npm run pdf:export"
},
"devDependencies": {
"mermaid": "^11.14.0",
"pdf-lib": "^1.17.1",
"playwright": "^1.59.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitepress": "^1.6.4",
+294
View File
@@ -0,0 +1,294 @@
import path from 'node:path'
import { promises as fs } from 'node:fs'
import { chromium, devices, type BrowserContext, type Page } from 'playwright'
import { PDFDocument } from 'pdf-lib'
import {
artifactsRoot,
discoverCoursePages,
ensureDirectory,
pdfOutputRoot,
startStaticServer,
toAbsoluteSiteUrl,
type Language
} from './export-site-utils'
const PDF_EXPORT_CSS = `
.VPNav,
.VPLocalNav,
.VPSidebar,
.aside,
.VPDocFooter,
.back-to-top-btn,
.VPNavBarExtra,
.VPNavBarSocialLinks,
.VPNavBarAppearance,
.VPNavBarTranslations,
.VPSocialLinks,
.VPDocOutlineDropdown,
.VPDocAside,
.VPLocalNavOutlineDropdown {
display: none !important;
}
.Layout,
.VPContent,
.VPDoc,
.VPDoc .container,
.VPDoc .content,
.VPDoc .content-container,
.vp-doc,
.vp-doc .container {
max-width: none !important;
width: auto !important;
margin: 0 !important;
padding: 0 !important;
}
.VPContent {
padding-top: 0 !important;
}
.vp-doc h1 {
font-size: 28px !important;
margin-top: 0 !important;
}
.vp-doc h2 {
page-break-after: avoid;
}
pre,
blockquote,
table,
.mermaid,
.custom-block {
break-inside: avoid;
}
body {
background: #ffffff !important;
}
`
async function main() {
const languages = parseRequestedLanguages(process.argv.slice(2))
const tempRoot = path.join(artifactsRoot, 'pdfs/.tmp')
await fs.rm(tempRoot, { recursive: true, force: true })
await ensureDirectory(tempRoot)
await ensureDirectory(pdfOutputRoot)
const server = await startStaticServer()
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext(devices['Desktop Chrome'])
try {
for (const language of languages) {
await buildLanguagePdf(context, server.origin, language, tempRoot)
}
} finally {
await context.close()
await browser.close()
await server.close()
}
}
async function buildLanguagePdf(
context: BrowserContext,
origin: string,
language: Language,
tempRoot: string
) {
const pages = await discoverCoursePages(language)
const tempLanguageDir = path.join(tempRoot, language)
const outputPdf = path.join(pdfOutputRoot, `learn-harness-engineering-${language}.pdf`)
const manifestPath = path.join(pdfOutputRoot, `learn-harness-engineering-${language}.json`)
await fs.rm(tempLanguageDir, { recursive: true, force: true })
await ensureDirectory(tempLanguageDir)
const manifest: Array<{ title: string; routePath: string }> = []
const tempPdfPaths: string[] = []
let pageIndex = 1
for (const entry of pages) {
const page = await context.newPage()
const url = toAbsoluteSiteUrl(origin, entry.routePath)
await page.goto(url, { waitUntil: 'networkidle' })
await page.addStyleTag({ content: PDF_EXPORT_CSS })
await page.emulateMedia({ media: 'screen' })
const title = await extractPageTitle(page, entry.titleHint)
const safeSlug = entry.routePath
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replace(/[/.]+/g, '-')
.replace(/-+/g, '-')
.toLowerCase()
const tempPdfPath = path.join(
tempLanguageDir,
`${String(pageIndex).padStart(2, '0')}-${safeSlug || language}.pdf`
)
await page.pdf({
path: tempPdfPath,
format: 'A4',
printBackground: true,
margin: {
top: '14mm',
bottom: '14mm',
left: '12mm',
right: '12mm'
},
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate:
'<div style="width:100%;font-size:9px;padding:0 12mm;color:#777;display:flex;justify-content:center;"><span class="pageNumber"></span>/<span class="totalPages"></span></div>'
})
await page.close()
manifest.push({ title, routePath: entry.routePath })
tempPdfPaths.push(tempPdfPath)
console.log(`Rendered ${language.toUpperCase()} PDF section: ${title}`)
pageIndex += 1
}
const coverPdfPath = path.join(tempLanguageDir, `00-cover-${language}.pdf`)
await renderCoverPage(context, coverPdfPath, language, manifest)
await mergePdfs([coverPdfPath, ...tempPdfPaths], outputPdf)
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
console.log(`Built ${outputPdf}`)
}
async function extractPageTitle(page: Page, fallback: string) {
const heading = await page.locator('h1').first().textContent().catch(() => null)
const title = heading?.trim()
if (title) return title
const documentTitle = await page.title()
return documentTitle.split('|')[0].trim() || fallback
}
async function renderCoverPage(
context: BrowserContext,
outputPath: string,
language: Language,
manifest: Array<{ title: string; routePath: string }>
) {
const page = await context.newPage()
const generatedAt = new Date().toISOString().slice(0, 10)
const languageLabel = language === 'en' ? 'English' : '简体中文'
const contents = manifest
.map(
(entry) =>
`<li><span>${escapeHtml(entry.title)}</span><code>${escapeHtml(entry.routePath)}</code></li>`
)
.join('')
await page.setContent(
`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
margin: 48px;
font-family: Georgia, "Times New Roman", serif;
color: #1a1a1a;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
}
p.meta {
margin: 0 0 24px;
color: #555;
font-size: 14px;
}
h2 {
margin: 32px 0 12px;
font-size: 20px;
}
ol {
margin: 0;
padding-left: 24px;
}
li {
margin: 0 0 8px;
line-height: 1.5;
}
code {
display: inline-block;
margin-left: 8px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<h1>Learn Harness Engineering</h1>
<p class="meta">${languageLabel} coursebook PDF · generated ${generatedAt}</p>
<h2>Included sections</h2>
<ol>${contents}</ol>
</body>
</html>`,
{ waitUntil: 'load' }
)
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: {
top: '14mm',
bottom: '14mm',
left: '12mm',
right: '12mm'
}
})
await page.close()
}
async function mergePdfs(inputPaths: string[], outputPath: string) {
const merged = await PDFDocument.create()
for (const inputPath of inputPaths) {
const sourceBytes = await fs.readFile(inputPath)
const source = await PDFDocument.load(sourceBytes)
const copiedPages = await merged.copyPages(source, source.getPageIndices())
for (const copiedPage of copiedPages) {
merged.addPage(copiedPage)
}
}
await fs.writeFile(outputPath, await merged.save())
}
function parseRequestedLanguages(args: string[]): Language[] {
const languageIndex = args.findIndex((arg) => arg === '--lang')
if (languageIndex === -1) {
return ['en', 'zh']
}
const value = args[languageIndex + 1]
if (value === 'en' || value === 'zh') {
return [value]
}
throw new Error(`Unsupported language: ${value}`)
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+41
View File
@@ -0,0 +1,41 @@
import path from 'node:path'
import { chromium, devices } from 'playwright'
import {
ensureDirectory,
readmeScreenshotTargets,
readmeScreenshotsRoot,
startStaticServer,
toAbsoluteSiteUrl
} from './export-site-utils'
async function main() {
await ensureDirectory(readmeScreenshotsRoot)
const server = await startStaticServer()
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext(devices['Desktop Chrome'])
try {
for (const target of readmeScreenshotTargets) {
const page = await context.newPage()
await page.goto(toAbsoluteSiteUrl(server.origin, target.routePath), {
waitUntil: 'networkidle'
})
await page.screenshot({
path: path.join(readmeScreenshotsRoot, target.outputName),
fullPage: false
})
await page.close()
console.log(`Captured ${target.outputName}`)
}
} finally {
await context.close()
await browser.close()
await server.close()
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+235
View File
@@ -0,0 +1,235 @@
import http from 'node:http'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { promises as fs } from 'node:fs'
export type Language = 'en' | 'zh'
export type CoursePage = {
filePath: string
routePath: string
titleHint: string
}
export type ScreenshotTarget = {
language: Language
routePath: string
outputName: string
}
export const repoRoot = process.cwd()
export const docsRoot = path.resolve(repoRoot, 'docs')
export const distRoot = path.resolve(repoRoot, 'docs/.vitepress/dist')
export const artifactsRoot = path.resolve(repoRoot, 'artifacts')
export const pdfOutputRoot = path.resolve(artifactsRoot, 'pdfs')
export const readmeScreenshotsRoot = path.resolve(docsRoot, 'public/screenshots/readme')
export const docsBasePath = normalizeBasePath(
process.env.DOCS_BASE_PATH || '/learn-harness-engineering/'
)
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
}
export const readmeScreenshotTargets: ScreenshotTarget[] = [
{ language: 'en', routePath: '/en/', outputName: 'en-home.png' },
{
language: 'en',
routePath: '/en/lectures/lecture-01-why-capable-agents-still-fail/',
outputName: 'en-lecture-01.png'
},
{ language: 'en', routePath: '/en/resources/', outputName: 'en-resources.png' },
{ language: 'zh', routePath: '/zh/', outputName: 'zh-home.png' },
{
language: 'zh',
routePath: '/zh/lectures/lecture-01-why-capable-agents-still-fail/',
outputName: 'zh-lecture-01.png'
},
{ language: 'zh', routePath: '/zh/resources/', outputName: 'zh-resources.png' }
]
export async function ensureDirectory(targetPath: string) {
await fs.mkdir(targetPath, { recursive: true })
}
export function toAbsoluteSiteUrl(origin: string, routePath: string) {
const normalizedOrigin = origin.replace(/\/$/, '')
const normalizedRoute = routePath.startsWith('/') ? routePath : `/${routePath}`
return `${normalizedOrigin}${docsBasePath.replace(/\/$/, '')}${normalizedRoute}`
}
export async function discoverCoursePages(language: Language): Promise<CoursePage[]> {
const languageRoot = path.resolve(docsRoot, language)
const files = await walkMarkdownFiles(languageRoot)
return files
.filter((filePath) => shouldIncludeInCoursePdf(path.relative(languageRoot, filePath).replace(/\\/g, '/')))
.sort((left, right) => compareCourseFiles(language, left, right))
.map((filePath) => ({
filePath,
routePath: sourceFileToRoutePath(language, filePath),
titleHint: path.basename(filePath, '.md')
}))
}
export async function startStaticServer(rootDir = distRoot) {
const server = http.createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1')
const filePath = resolveStaticFilePath(rootDir, requestUrl.pathname)
if (!filePath) {
response.statusCode = 404
response.end('Not Found')
return
}
const data = await fs.readFile(filePath)
const ext = path.extname(filePath)
response.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream')
response.end(data)
} catch (error) {
response.statusCode = 500
response.end(error instanceof Error ? error.message : 'Unknown server error')
}
})
const address = await new Promise<http.AddressInfo>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const currentAddress = server.address()
if (!currentAddress || typeof currentAddress === 'string') {
reject(new Error('Failed to bind static export server'))
return
}
resolve(currentAddress)
})
})
return {
origin: `http://127.0.0.1:${address.port}`,
close: async () =>
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
}
function normalizeBasePath(input: string) {
const withLeadingSlash = input.startsWith('/') ? input : `/${input}`
return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`
}
async function walkMarkdownFiles(targetDir: string): Promise<string[]> {
const entries = await fs.readdir(targetDir, { withFileTypes: true })
const nested = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(targetDir, entry.name)
if (entry.isDirectory()) {
return await walkMarkdownFiles(fullPath)
}
if (entry.isFile() && entry.name.endsWith('.md')) {
return [fullPath]
}
return []
})
)
return nested.flat()
}
function shouldIncludeInCoursePdf(relativePath: string) {
if (relativePath.includes('/code/')) return false
if (relativePath === 'index.md') return true
if (relativePath.startsWith('lectures/')) return true
if (relativePath.startsWith('projects/')) return true
if (relativePath === 'resources/index.md') return true
if (relativePath === 'resources/templates/index.md') return true
if (relativePath.startsWith('resources/reference/')) return true
if (relativePath.startsWith('resources/openai-advanced/')) return true
return false
}
function compareCourseFiles(language: Language, left: string, right: string) {
const languageRoot = path.resolve(docsRoot, language)
const leftRelative = path.relative(languageRoot, left).replace(/\\/g, '/')
const rightRelative = path.relative(languageRoot, right).replace(/\\/g, '/')
const leftWeight = sortWeight(leftRelative)
const rightWeight = sortWeight(rightRelative)
if (leftWeight !== rightWeight) return leftWeight - rightWeight
return leftRelative.localeCompare(rightRelative)
}
function sortWeight(relativePath: string) {
if (relativePath === 'index.md') return 0
if (relativePath.startsWith('lectures/')) return 1
if (relativePath.startsWith('projects/')) return 2
if (relativePath === 'resources/index.md') return 3
if (relativePath.startsWith('resources/templates/')) return 4
if (relativePath.startsWith('resources/reference/')) return 5
if (relativePath.startsWith('resources/openai-advanced/')) return 6
return 99
}
function sourceFileToRoutePath(language: Language, filePath: string) {
const languageRoot = path.resolve(docsRoot, language)
const relativePath = path.relative(languageRoot, filePath).replace(/\\/g, '/')
if (relativePath === 'index.md') {
return `/${language}/`
}
if (relativePath.endsWith('/index.md')) {
return `/${language}/${relativePath.slice(0, -'index.md'.length)}`
}
return `/${language}/${relativePath.replace(/\.md$/, '')}`
}
function resolveStaticFilePath(rootDir: string, pathname: string) {
const normalizedPath = pathname.replace(/\/+/g, '/')
if (!normalizedPath.startsWith(docsBasePath)) {
return null
}
const relativePath = normalizedPath.slice(docsBasePath.length).replace(/^\/+/, '')
const candidates = buildPathCandidates(rootDir, relativePath)
return candidates.find((candidate) => existsSync(candidate)) || null
}
function buildPathCandidates(rootDir: string, relativePath: string) {
if (!relativePath) {
return [path.join(rootDir, 'index.html')]
}
if (relativePath.endsWith('/')) {
return [path.join(rootDir, relativePath, 'index.html')]
}
if (path.extname(relativePath)) {
return [path.join(rootDir, relativePath)]
}
return [
path.join(rootDir, `${relativePath}.html`),
path.join(rootDir, relativePath, 'index.html')
]
}