本教程解决一个被普遍忽视的难题:Skill 本身是无状态的。你会学到如何用文件系统 + 状态脚本 + Hook,在无状态架构上"搭出"一个能跨会话续跑的有状态 Skill,并做出一个可运行的批量迁移追踪器。
Claude Code 的 Skill 是扩展能力的核心载体,但它有一个被多数教程回避的本质特性:Skill 在会话之间是无状态的——每次启动都是一张白纸。本教程教你突破这一限制。
学习目标
SessionStart / Stop 两类 Hook 实现自动续跑与断点恢复适用人群:已能创建基础 SKILL.md、希望做长流程自动化(批量重构、框架升级、CI 迁移、文档生成)的中高级开发者。
| 项 | 要求 |
|---|---|
| Claude Code | v2.1.0+,CLI 可正常启动 |
| 基础 | 会写最简 SKILL.md(frontmatter + 正文) |
| 运行时 | Node.js 18+(Hook 脚本用)、jq(状态读写用) |
| 心智模型 | 理解"上下文窗口""会话""Hook 事件"三个概念 |
不必精通 Hook,本教程会给出可直接复制的
settings.json片段。
很多 Skill 用着用着就"失忆"了:上一次迁移到一半,新开个会话它又从头开始。这不是 bug,而是架构设计。
官方文档与社区技术深度解析一致指出,Skill 具有以下本质特征:
~/.claude/skills/(个人级)、.claude/skills/(项目级)、插件目录三处文件会跨会话存在。name + description(约 30–50 token / Skill);被判定相关时才载入 SKILL.md 正文(通常 < 5000 token);引用文件与脚本按需读取,脚本代码本身不进上下文,只有脚本输出消耗 token。这条最后的事实极其关键:把状态读写的复杂逻辑放进脚本,是"免费的"——因为脚本源码不占上下文,只有它打印的结果才占。这正是有状态 Skill 的工程支点。
预期结果:建立"Skill 无状态 → 用文件 + 脚本补状态"的心智模型。
常见问题
我们要做一个 migration-tracker:把某个目录下的旧 API 调用批量迁移到新实现(例如 mysql_* → PDO),42 个文件,可能跨多个会话才能干完。
状态文件用一个简单 JSON,字段语义清晰、可被 jq 操作:
{
"skill_version": "1.0.0",
"created_at": "2026-06-15T10:00:00Z",
"updated_at": "2026-06-15T11:30:00Z",
"goal": "将 src/legacy 下所有 mysql_* 调用迁移到 PDO",
"status": "in_progress",
"total": 42,
"done": 17,
"queue": ["src/legacy/UserRepo.php", "src/legacy/OrderRepo.php"],
"completed": ["src/legacy/Auth.php", "src/legacy/Session.php"],
"current_file": "src/legacy/UserRepo.php",
"current_line": 128,
"lessons": ["mysql_query 第三参数是连接句柄,PDO 用 prepare+execute 替代"],
"last_session_summary": "完成 Auth/Session,修复 3 处 SQL 注入"
}
目录骨架(标准分层,符合渐进披露):
.claude/skills/migration-tracker/
├── SKILL.md # 轻量指令,编排工作流
├── scripts/
│ ├── state.sh # 状态读写(jq 实现,代码不进上下文)
│ ├── session-brief.js # SessionStart hook:注入续跑简报
│ └── checkpoint.js # Stop hook:落盘摘要
└── references/
└── state-schema.md # 字段语义详解(按需读取)
状态文件本身不放 Skill 目录里(那是"能力"定义),而放业务位置
.claude/migration-state/state.json,与代码一起进版本控制,团队共享进度。
这是整个 Skill 的"状态引擎"。用 jq 保证原子性与可组合性:
#!/usr/bin/env bash
# scripts/state.sh — migration-tracker 状态引擎
# 用法: state.sh <init|show|next|done|checkpoint|summary>
set -euo pipefail
STATE_DIR="${MIGRATION_STATE_DIR:-.claude/migration-state}"
STATE_FILE="$STATE_DIR/state.json"
mkdir -p "$STATE_DIR"
stamp() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
case "${1:-help}" in
init)
goal="${2:?usage: state.sh init <goal>}"
printf '%s' "{\"skill_version\":\"1.0.0\",\"created_at\":\"$(stamp)\",\"goal\":\"$goal\",\"status\":\"in_progress\",\"total\":0,\"done\":0,\"queue\":[],\"completed\":[],\"current_file\":null,\"current_line\":0,\"lessons\":[],\"last_session_summary\":null}" > "$STATE_FILE"
echo "✓ state initialized at $STATE_FILE"
;;
show) cat "$STATE_FILE" ;;
enqueue)
# 从 stdin 接收文件列表,并入 queue 并去重
tmp=$(mktemp); cat > "$tmp"
jq --slurpfile q "$tmp" '.queue = ((.queue + $q[0]) | unique) | .total = (.queue|length)' "$STATE_FILE" > "$STATE_FILE.new"
mv "$STATE_FILE.new" "$STATE_FILE"; rm -f "$tmp"
echo "✓ enqueued, total=$(jq '.total' "$STATE_FILE")"
;;
next)
# 返回第一个"在 queue 但不在 completed"的文件
jq -r '(.queue - .completed)[0] // "QUEUE_EMPTY"' "$STATE_FILE"
;;
checkpoint)
file="${2:?}"; line="${3:-0}"
jq --arg f "$file" --argjson l "$line" --arg t "$(stamp)" \
'.current_file=$f | .current_line=$l | .updated_at=$t' "$STATE_FILE" > "$STATE_FILE.new"
mv "$STATE_FILE.new" "$STATE_FILE"
echo "✓ checkpoint $file:$line"
;;
done)
file="${2:?}"
jq --arg f "$file" --arg t "$(stamp)" \
'.completed += [$f] | .done = (.completed|length) | .current_file = null | .updated_at=$t' \
"$STATE_FILE" > "$STATE_FILE.new"
mv "$STATE_FILE.new" "$STATE_FILE"
echo "✓ completed $file, progress=$(jq -r '.done + "/" + (.total|tostring)' "$STATE_FILE")"
;;
summary)
text="${2:?usage: state.sh summary <text>}"
jq --arg s "$text" --arg t "$(stamp)" \
'.last_session_summary=$s | .updated_at=$t' "$STATE_FILE" > "$STATE_FILE.new"
mv "$STATE_FILE.new" "$STATE_FILE"
echo "✓ summary saved"
;;
*) echo "commands: init|show|enqueue|next|checkpoint|done|summary"; exit 1 ;;
esac
设计要点(幂等性)
next 用集合差 queue - completed,重复调用结果一致,天然幂等。*.new 临时文件 + mv 原子替换,避免中断写坏 JSON。checkpoint 在处理一个文件之前落盘断点,保证最坏情况只丢"当前这一个文件的当前这一行之后"的进度。预期输出
$ bash scripts/state.sh next
src/legacy/UserRepo.php
$ bash scripts/state.sh checkpoint src/legacy/UserRepo.php 128
✓ checkpoint src/legacy/UserRepo.php:128
常见问题
jq 报 parse error? 多半是中途被 Ctrl-C 打断直接写了半截 JSON。原子替换(.new + mv)正是为此——要么旧值完整、要么新值完整,不会出现半截。enqueue 慢? 没关系,脚本是子进程,不占你的上下文 token,慢只影响墙钟时间。正文要"薄"——把复杂逻辑下沉到脚本,SKILL.md 只负责编排与触发词。description 是触发命中的唯一判据,必须写满场景与关键词:
---
name: migration-tracker
description: 跟踪并执行跨文件的代码迁移、批量重构、框架升级、命名/ API 批量替换等长流程任务。当用户说"批量迁移""重构整个目录""把 X 全部换成 Y""升级到新 API""续跑迁移""继续上次的迁移"时使用。维护跨会话状态文件,支持从断点恢复,不会重复处理已完成文件。
allowed-tools: Read, Edit, Bash, Glob, Grep
---
# 迁移追踪 Skill
## 核心铁律:无状态架构上的状态工程
你跨会话无记忆。本 Skill 用 `.claude/migration-state/state.json` 作为唯一事实源。
**绝不在内存里维护进度——一律落盘,下次一律先读盘。**
## 工作流
1. **恢复或初始化**:`bash scripts/state.sh show`
- 报错"不存在"→ 询问迁移目标 → `scripts/state.sh init "<目标>"`,
再用 `Glob`/`Grep` 扫描受影响文件,管道喂给 `scripts/state.sh enqueue`。
2. **取下一项**:`next=$(bash scripts/state.sh next)`。
若返回 `QUEUE_EMPTY` → 置 `status=done`,汇总报告,结束。
3. **先落断点**:`bash scripts/state.sh checkpoint "$next" 0`(处理前)。
4. **执行**:`Read` 该文件 → `Edit` 完成迁移。
5. **标记完成**:`bash scripts/state.sh done "$next"`;遇到非平凡坑写入 lessons。
6. **回到第 2 步**循环。
字段语义见 `references/state-schema.md`(仅当需要扩展字段时再读)。
为什么这么薄? 因为 Stage 2 加载正文要占 token,正文越薄,常驻成本越低;真正的复杂度都在脚本里(Stage 3 按需,且代码不入上下文)。
常见问题
description 太泛(如"分析代码")。改成场景化长句(如上)命中率立刻上升。allowed-tools 要不要给 Write? 不给。状态写都走脚本,Skill 本体只需 Read/Edit/Bash/Glob/Grep,最小权限。到这一步,Skill 已经能手动续跑了(新会话里说"继续迁移"即可)。但更优雅的是自动:新会话一开,自己把上次进度念出来。
Skill 不能自己注册 Hook——Hook 是 Harness 层的(settings.json),但 Skill 可以自带脚本,让用户一次性接线:
scripts/session-brief.js(SessionStart 事件,其 stdout 会作为上下文注入):
#!/usr/bin/env node
// SessionStart hook: 读取状态,输出续跑简报(stdout 注入到 Claude 上下文)
const fs = require('fs'); const path = require('path');
const f = path.join(process.cwd(), '.claude/migration-state/state.json');
if (!fs.existsSync(f)) process.exit(0); // 无状态→静默,不污染新项目
const s = JSON.parse(fs.readFileSync(f, 'utf8'));
if (s.status === 'done') process.exit(0); // 已完成→不打扰
const remain = (s.queue.length || s.total) - s.done;
console.log(`[migration-tracker 续跑简报] 目标: ${s.goal}`);
console.log(`进度: ${s.done}/${s.total}, 剩余 ${remain} 项, 断点: ${s.current_file || '队首'}`);
if (s.last_session_summary) console.log(`上次: ${s.last_session_summary}`);
console.log(`说"继续迁移"即可从断点恢复。`);
scripts/checkpoint.js(Stop 事件,主线程结束时触发,可调度后台 agent 写摘要):
#!/usr/bin/env node
// Stop hook: 会话结束前提醒落盘摘要(这里仅做轻量提示,避免阻塞)
const fs = require('fs'); const path = require('path');
const f = path.join(process.cwd(), '.claude/migration-state/state.json');
if (!fs.existsSync(f)) process.exit(0);
const s = JSON.parse(fs.readFileSync(f, 'utf8'));
if (s.status !== 'in_progress') process.exit(0);
// stdout 在 Stop 事件会作为 system message 回传给模型,触发它执行 summary 落盘
console.log(`[migration-tracker] 本会话即将结束,进度 ${s.done}/${s.total}。请用 state.sh summary "<本次做了什么>" 落盘后再结束。`);
在 settings.json 接线(项目级,团队共享):
{
"hooks": {
"SessionStart": [
{ "matcher": "*", "hooks": [
{ "type": "command",
"command": "node .claude/skills/migration-tracker/scripts/session-brief.js" }
]}
],
"Stop": [
{ "matcher": "", "hooks": [
{ "type": "command",
"command": "node .claude/skills/migration-tracker/scripts/checkpoint.js" }
]}
]
}
}
接线后重启 Claude Code。Skill 文件与 Hook 的修改都不会热生效——发现扫描只在初始化时发生。
常见问题
command 的相对路径与可执行权限(chmod +x)。可用 claude --debug 看加载日志,无效 YAML / 报错的脚本会静默失败。端到端验证清单:
init → enqueue 喂 3 个测试文件 → show 确认 total=3。next 得文件 A → checkpoint → 手动 Edit → done A。status=done,简报静默退出。关键观察:done 集合是去重的、next 是幂等的——这就是续跑正确性的数学保证。
| 维度 | 原生 Skill(无状态) | 本教程方案(有状态) |
|---|---|---|
| 跨会话记忆 | ❌ 无 | ✅ 文件系统持久化 |
| 上下文成本 | 仅 metadata | + 状态文件按需读 |
| 续跑能力 | ❌ 从头开始 | ✅ 断点恢复 |
| 复杂度 | 低 | 中(脚本 + Hook) |
| 适用场景 | 单次、原子任务 | 长流程、批量、可中断 |
| 失败模式 | 无状态可丢 | 需防"半截 JSON" |
判断准则:任务能在单会话内原子完成 → 用原生 Skill;任务必然跨会话、需要进度可见可恢复 → 上有状态方案。
把上面零碎片段组装成一个完整可复现的最小项目(约 5 分钟跑通):
# 1. 建骨架
mkdir -p .claude/skills/migration-tracker/{scripts,references}
mkdir -p .claude/migration-state
mkdir -p src/legacy && echo '<?php mysql_query("SELECT 1");' > src/legacy/Auth.php
# 2. 落脚本(粘贴步骤3、步骤5 的三个文件)
chmod +x .claude/skills/migration-tracker/scripts/state.sh
# 3. 初始化状态 + 入队
bash .claude/skills/migration-tracker/scripts/state.sh init "mysql_* → PDO"
grep -rl 'mysql_' src/legacy | bash .claude/skills/migration-tracker/scripts/state.sh enqueue
# 4. 接 Hook(粘贴步骤5 的 settings.json),重启 Claude Code
# 5. 在 Claude Code 里说:"用 migration-tracker 继续迁移"
# → 观察它读状态、取队首、checkpoint、Edit、done、循环
验收标准
completed 里的文件。state.json 始终是合法 JSON(原子替换保证)。status=done,简报不再打扰。flowchart TD
A[新会话启动] --> B[SessionStart Hook<br/>读 state.json]
B --> C{state 存在且未完成?}
C -- 否 --> Z[静默, 不打扰]
C -- 是 --> E[注入续跑简报到上下文]
E --> F[Stage2 加载 SKILL.md 正文]
F --> G[state.sh next 取队首]
G --> H{QUEUE_EMPTY?}
H -- 是 --> K[status=done 汇总结束]
H -- 否 --> I[checkpoint 落断点]
I --> J[Read + Edit 执行迁移]
J --> L[state.sh done 标记完成]
L --> M{用户中断?}
M -- 是 --> N[Stop Hook 提醒落 summary]
M -- 否 --> G
N --> O[下个会话从 A 续跑]
state.sh 加 flock 文件锁,或把 queue 拆成 per-user 分片。skill_version 检查与一次性迁移,避免旧状态把新 Skill 带崩。lessons 升级为 journal → lesson → rules 三级蒸馏(参考社区 claude-project-skill 的做法),让踩坑经验沉淀为可复用规则,而非一次性笔记。| 维度 | 评估 |
|---|---|
| 适合训练营 | ✅ 中高级研发提效营、AI 工程化营、企业自动化营 |
| 前置模块 | 需先完成"Skill 基础创建""Hook 入门"两节 |
| 教学时长 | 讲解 45 min + 动手实操 90 min,合计约 3.5 课时 |
| 学员产出 | 一个可带回自己项目复用的有状态 Skill 骨架 |
| 难点 | Hook 接线调试、幂等性思维、原子写 |
建议作为"Skill 进阶"模块的压轴课——它把"无状态"这个反直觉真相讲透后,学员对整个 Skill 体系的理解会跨一个台阶。
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
