@netpilot/skills 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,13 +4,13 @@
4
4
  "owner": {
5
5
  "name": "NetPilot"
6
6
  },
7
- "description": "NetPilot 个人 AI 工程协作插件目录",
7
+ "description": "NetPilot AI 工程协作插件目录",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "netpilot-skills",
11
11
  "source": "./",
12
12
  "description": "中文工程协作 skills 与可组合工作流",
13
- "version": "0.4.0",
13
+ "version": "0.7.0",
14
14
  "author": {
15
15
  "name": "NetPilot"
16
16
  },
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "netpilot-skills",
4
4
  "displayName": "NetPilot Skills",
5
- "version": "0.4.0",
5
+ "version": "0.7.0",
6
6
  "description": "面向 Claude Code 的中文工程协作 skills 与可组合工作流",
7
7
  "author": {
8
8
  "name": "NetPilot"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "netpilot-skills",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "面向 Codex 的中文工程协作 skills 与可组合工作流",
5
5
  "author": {
6
6
  "name": "NetPilot"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.7.0
8
+
9
+ - 新增用户级 `security-reviewer` 与 `migration-reviewer`:前者按应用、Agent/Tool 与供应链 profile 审查信任边界,后者按 Expand → Migrate → Contract 审查 schema、回填、兼容切换与恢复;两者均保持 high / read-only 和统一 P0–P3 证据契约。
10
+ - 扩展 `backend-reviewer` 的 API 消费者兼容、查询、索引、Redis 与 fail-safe 检查,扩展 `frontend-reviewer` 的 contract 漂移、服务端授权和浏览器 console/network 证据要求。
11
+ - 在 Agent 编写规范中固定六个默认角色的主任务路由、去重与串行降级边界;打包、README 与 roster 测试同步覆盖 6 个 Codex Agents。
12
+ - 与 `@netpilot/harness` 协调发布同一 `v0.7.0` 标签;Harness 只路由用户级安全与 migration reviewer,不在项目 `.codex/agents` 复制同名角色。
13
+
14
+ ## 0.6.0
15
+
16
+ - 将用户级与项目级安装状态迁移到中性的 `.agents/.state/skills-installer`;旧 `.netpilot-skills` 在 apply 时经锁保护、目录身份复核后原子迁移,preview 仅报告计划。旧版客户端留下的精确空目录可安全自愈,真实双状态或目标冲突仍整批停止。
17
+ - 项目安装继续只写入 `.agents/skills`,不安装项目级 Agents;用户级默认仍安装 Codex Skills 与少量 Codex Agents。
18
+ - 与 `@netpilot/harness` 协调发布同一 `v0.6.0` 标签,便于在新电脑或新项目中识别兼容的一组安装器能力;两个包仍保持独立职责和发布契约。
19
+
7
20
  ## 0.4.0
8
21
 
9
22
  - 将工程与生产力方法扩展为 21 个去个人化 skills,按上游原有结构恢复完整工作流、步骤内门禁、方法自身的反模式,以及有实际价值的 `references/` 和跨平台脚本;不再为每个 skill 强加统一尾部章节。
package/README.md CHANGED
@@ -42,7 +42,7 @@ npm install --global @netpilot/skills
42
42
  netpilot-skills
43
43
  ```
44
44
 
45
- 安装器不会覆盖未受管理或用户修改过的同名文件;发生冲突时整批停止。项目范围只安装 Skills,项目级 Agents 仍由 [NetPilot Harness](https://github.com/netpilot-z/harness) 管理。
45
+ 安装器不会覆盖未受管理或用户修改过的同名文件;发生冲突时整批停止。同步状态保存在 `.agents/.state/skills-installer`:用户安装位于 home,项目安装位于项目根;旧 `.netpilot-skills` 会在下一次实际安装时原子迁移,预览只展示计划。旧版安装器在新状态旁留下的空旧目录会安全自愈,含内容的双状态仍会阻断。项目范围只安装 Skills,项目级 Agents 仍由 [NetPilot Harness](https://github.com/netpilot-z/harness) 管理。
46
46
 
47
47
  ## 核心用法
48
48
 
@@ -93,6 +93,8 @@ Codex 使用 `$skill-name`;Claude Code 用户级安装使用 `/skill-name`。
93
93
  | --- | --- | --- |
94
94
  | `frontend-reviewer` | 前端状态、交互、可访问性和测试审查 | high / read-only |
95
95
  | `backend-reviewer` | 接口、权限、事务和数据一致性审查 | high / read-only |
96
+ | `security-reviewer` | 应用、Agent、Tool 与供应链安全审查 | high / read-only |
97
+ | `migration-reviewer` | schema、回填、兼容切换与恢复审查 | high / read-only |
96
98
  | `architecture-designer` | 模块边界、依赖和迁移方案比较 | high / read-only |
97
99
  | `test-verifier` | 执行测试、类型检查、lint 和构建 | medium / workspace-write |
98
100
 
@@ -1,11 +1,13 @@
1
1
  name = "backend-reviewer"
2
- description = "只读审查后端与接口变更的契约、权限、数据一致性、事务并发、幂等性、可观测性和测试风险。"
2
+ description = "只读审查后端、接口与数据访问变更的契约兼容、权限、事务并发、幂等性、可观测性和测试风险。"
3
3
  model_reasoning_effort = "high"
4
4
  sandbox_mode = "read-only"
5
5
  developer_instructions = """
6
6
  像后端代码所有者一样审查,优先正确性、安全边界和数据完整性。
7
7
  先读取适用的 AGENTS.md、规格、后端标准、接口契约、数据模型和完整 diff,再追踪必要的调用方、迁移与测试。
8
- 重点检查输入验证、认证授权、租户隔离、事务边界、并发与竞态、幂等、错误语义、重试、资源释放、日志与指标以及回归测试。
8
+ 重点检查输入验证、认证授权、租户隔离、事务边界、并发与竞态、幂等、错误语义、有限重试、资源释放、日志与指标以及回归测试。
9
+ 接口变更同时检查字段、枚举、分页、错误码、deprecation 和消费者兼容;数据库与缓存变更检查查询规模、索引、N+1、连接生命周期、Redis 失效与 fail-safe 行为。
10
+ 破坏性 schema 变更、回填或数据修复交给 migration reviewer;需要完整攻击路径或供应链判断时交给 security reviewer,避免在本角色重复专项清单。
9
11
  不要修改文件,不要执行生产或数据写入,不要把缺少个人偏好的抽象当成问题。
10
12
  返回固定结果:status 只能是 completed 或 blocked;finding priority 只能是 P0、P1、P2 或 P3。
11
13
  每个 finding 返回 file 与 line、trigger、impact、evidence 和 minimal fix,并明确区分已证实问题、未知项和建议验证。
@@ -5,7 +5,8 @@ sandbox_mode = "read-only"
5
5
  developer_instructions = """
6
6
  像前端代码所有者一样审查,但只报告有证据、可触发且值得修复的问题。
7
7
  先读取适用的 AGENTS.md、规格、前端标准、设计系统约束和完整 diff,再检查必要的调用方与测试。
8
- 重点检查行为正确性、状态同步、竞态与取消、错误和加载状态、键盘与屏幕阅读器可用性、组件职责、渲染成本以及测试缺口。
8
+ 重点检查行为正确性、状态同步、竞态与取消、loading、error、empty 与 success 状态、键盘与屏幕阅读器可用性、组件职责、渲染成本以及测试缺口。
9
+ 检查前端 contract 与真实接口是否漂移,路由、菜单和按钮权限是否被误作服务端授权;涉及运行时交互时要求可复现的浏览器、console 与 network 证据,不能用静态推断冒充真实验收。
9
10
  不要修改文件,不要安装依赖,不要把纯风格偏好或无法证明影响的猜测列为 finding。
10
11
  返回固定结果:status 只能是 completed 或 blocked;finding priority 只能是 P0、P1、P2 或 P3。
11
12
  每个 finding 返回 file 与 line、trigger、impact、evidence 和 minimal fix,并明确区分已证实问题与推断。
@@ -0,0 +1,14 @@
1
+ name = "migration-reviewer"
2
+ description = "只读审查 schema migration、回填、数据修复、兼容切换与旧路径删除的安全性和可恢复性。"
3
+ model_reasoning_effort = "high"
4
+ sandbox_mode = "read-only"
5
+ developer_instructions = """
6
+ 只做迁移审查,不修改文件,不执行数据库或数据写入,不连接生产环境。
7
+ 先读取适用的 AGENTS.md、CONTEXT、数据库与兼容性标准、schema diff、migration、回填脚本、数据规模、消费者清单、发布与恢复证据。
8
+ 以 Expand → Migrate → Contract 为默认顺序,检查新旧代码与数据的兼容窗口、部署顺序、锁表与长事务、索引构建、默认值和非空约束、大表批处理、限速、checkpoint、可重入、幂等、差异核对和失败样本。
9
+ 检查旧写冻结、双写或双读的事实源、消费者迁移、删除前零使用证据,以及 backup restore、rollback 或 forward-fix 是否与真实失败模式匹配。
10
+ 不要把本地 schema 校验当成生产可执行证据;生产数据量、隔离级别、执行计划、维护窗口或恢复演练缺失时明确列为 unknown。
11
+ 返回固定结果:status 只能是 completed 或 blocked;finding priority 只能是 P0、P1、P2 或 P3。
12
+ 每个 finding 返回 file 与 line、phase、trigger、数据量或并发假设、impact、evidence 和 minimal fix,并区分已证实问题、推断和开放问题。
13
+ 没有可执行问题时显式返回 no findings;最后列出 unknowns、未覆盖消费者与未执行验证。
14
+ """
@@ -0,0 +1,13 @@
1
+ name = "security-reviewer"
2
+ description = "只读审查应用、Agent 与供应链变更中的信任边界、权限、敏感数据、注入和特权工具风险。"
3
+ model_reasoning_effort = "high"
4
+ sandbox_mode = "read-only"
5
+ developer_instructions = """
6
+ 只做安全审查,不修改文件,不执行外部写入、生产操作或攻击性探测。
7
+ 先读取适用的 AGENTS.md、CONTEXT、security standards、威胁模型、完整 diff、配置、契约和拒绝路径测试,再标出 trusted source、untrusted source、transform、sink、数据敏感度与授权点。
8
+ 按变更选择最小必要 profile:应用安全检查认证授权、租户隔离、输入验证、XSS、CSRF、SSRF、SQL、shell 与 path 注入、文件上传、Webhook 重放、secrets、PII 和支付边界;Agent 与 Tool 安全检查外部内容到 privileged sink 的 source → transform → sink 路径、工具组合权限、持久记忆和 prompt injection;供应链安全检查依赖与 lockfile、CI action、Plugin、MCP、构建脚本的 provenance、版本固定、最小权限、更新和撤销证据。
9
+ 不要把已连接工具等同于授权,不要仅依赖 prompt 过滤或模型自律宣称风险已消除;未命中的 profile 不展开通用清单。
10
+ 返回固定结果:status 只能是 completed 或 blocked;finding priority 只能是 P0、P1、P2 或 P3。
11
+ 每个 finding 返回 file 与 line、profile、source、transform、sink、trigger、impact、evidence 和 minimal fix,并区分已证实问题、推断和开放问题。
12
+ 没有可执行问题时显式返回 no findings;最后列出 unknowns、未验证边界与未执行验证,不把未覆盖范围写成通过。
13
+ """
@@ -48,6 +48,21 @@ Agent 不应成为完整工作流、项目规范副本或长期人格。实现
48
48
 
49
49
  只读 Agent 返回事实、可定位证据、推断、未知项和建议下一步。Reviewer 使用 `completed | blocked` 状态和 P0–P3 finding,并返回 trigger、impact、evidence 与 minimal fix。Architecture designer 固定返回 current state、options、recommendation、migration slices、validation、decisions 与 unknowns。Verifier 使用 `passed | failed | blocked`,返回实际命令、退出码、关键输出、未执行项、执行前后工作树、环境限制和 `workspace_change`;新增 tracked diff 时不得返回 passed。不得把建议写成已经验证的结论。
50
50
 
51
+ ## 默认路由
52
+
53
+ 主 agent 按任务的主要风险面选择最少数量的角色;同一 finding 不交给多个 reviewer 重复判断。Agent 不可用时由主 agent 串行执行相同检查,并保留相同证据与失败标准。
54
+
55
+ | 主任务类型 | Agent | 不应代替 |
56
+ | --- | --- | --- |
57
+ | 模块 Interface、依赖方向、数据流与迁移方案比较 | `architecture-designer` | diff finding 或实现 |
58
+ | 后端、接口、事务、查询与缓存审查 | `backend-reviewer` | 专项安全或 migration 审查 |
59
+ | 前端状态、交互、可访问性与浏览器证据审查 | `frontend-reviewer` | 真实浏览器验收或服务端授权 |
60
+ | 应用、Agent、Tool 与供应链安全审查 | `security-reviewer` | 渗透测试或外部 mutation |
61
+ | schema、回填、数据修复与兼容切换审查 | `migration-reviewer` | 数据库写入或生产执行 |
62
+ | 执行已有测试、typecheck、lint 与 build | `test-verifier` | 测试设计审查或实现修复 |
63
+
64
+ 普通代码定位继续使用内置 `explorer`,实现继续使用内置 `worker`。测试质量由适用 reviewer 或 `code-review` 的 Standards 轴判断,不能把 `test-verifier` 扩成既执行又评价自身证据的万能角色。
65
+
51
66
  ## 双宿主策略
52
67
 
53
68
  Skills 保持 Codex 与 Claude Code 共用单源。Agent 配置是宿主专用适配,不强求两种宿主使用相同格式、模型名或权限语义。真正支持 Claude Code Agent 时,在独立宿主目录中实现并建立能力映射,不复制 Skill 工作流。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netpilot/skills",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "面向 Codex 与 Claude Code 的中文工程协作 skills 与可选 Codex agents",
5
5
  "keywords": [
6
6
  "codex",
package/scripts/sync.mjs CHANGED
@@ -24,6 +24,10 @@ const HOST_DESTINATIONS = {
24
24
  };
25
25
 
26
26
  const CODEX_AGENT_DESTINATION = [".codex", "agents"];
27
+ const CURRENT_STATE_DESTINATION = [".agents", ".state", "skills-installer"];
28
+ const LEGACY_STATE_DESTINATION = [".netpilot-skills"];
29
+ const LAYOUT_LOCK_DIRECTORY = [".agents", ".state"];
30
+ const LAYOUT_LOCK_NAME = "skills-installer.layout.lock";
27
31
 
28
32
  const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
29
33
  const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
@@ -354,6 +358,100 @@ async function assertSafeHomeRoot(homeDir) {
354
358
  ]);
355
359
  }
356
360
 
361
+ function resolveStatePaths(homeDir) {
362
+ const currentStateDir = path.join(homeDir, ...CURRENT_STATE_DESTINATION);
363
+ const legacyStateDir = path.join(homeDir, ...LEGACY_STATE_DESTINATION);
364
+ return {
365
+ currentStateDir,
366
+ currentManifestPath: path.join(currentStateDir, "manifest.json"),
367
+ legacyStateDir,
368
+ legacyManifestPath: path.join(legacyStateDir, "manifest.json"),
369
+ layoutLockDir: path.join(homeDir, ...LAYOUT_LOCK_DIRECTORY),
370
+ };
371
+ }
372
+
373
+ function stateLayoutConflict(homeDir, reason, targetPath = homeDir) {
374
+ return new SyncConflictError([
375
+ {
376
+ host: "state",
377
+ component: "layout",
378
+ assetName: "state-layout",
379
+ skillName: "state-layout",
380
+ relativePath: ".",
381
+ targetPath,
382
+ action: "conflict",
383
+ reason,
384
+ },
385
+ ]);
386
+ }
387
+
388
+ async function inspectStateDirectory(homeDir, targetPath) {
389
+ const ancestor = await inspectAncestorPath(homeDir, targetPath);
390
+ if (!ancestor.safe) {
391
+ throw stateLayoutConflict(
392
+ homeDir,
393
+ `${ancestor.reason}:${ancestor.path}`,
394
+ targetPath,
395
+ );
396
+ }
397
+ try {
398
+ const stats = await lstat(targetPath);
399
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
400
+ throw stateLayoutConflict(
401
+ homeDir,
402
+ "状态路径必须是非符号链接目录",
403
+ targetPath,
404
+ );
405
+ }
406
+ return {
407
+ exists: true,
408
+ path: targetPath,
409
+ identity: { dev: stats.dev, ino: stats.ino },
410
+ };
411
+ } catch (error) {
412
+ if (error.code === "ENOENT") return { exists: false, path: targetPath };
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ function sameStateDirectoryIdentity(left, right) {
418
+ return left?.dev === right?.dev && left?.ino === right?.ino;
419
+ }
420
+
421
+ async function inspectStateLayout(homeDir, paths = resolveStatePaths(homeDir)) {
422
+ const [current, legacy] = await Promise.all([
423
+ inspectStateDirectory(homeDir, paths.currentStateDir),
424
+ inspectStateDirectory(homeDir, paths.legacyStateDir),
425
+ ]);
426
+ if (current.exists && legacy.exists) {
427
+ const legacyEntries = await readdir(legacy.path);
428
+ const latestLegacy = await inspectStateDirectory(homeDir, legacy.path);
429
+ if (
430
+ legacyEntries.length === 0 &&
431
+ latestLegacy.exists &&
432
+ sameStateDirectoryIdentity(legacy.identity, latestLegacy.identity)
433
+ ) {
434
+ return {
435
+ kind: "current-with-empty-legacy",
436
+ current,
437
+ legacy: latestLegacy,
438
+ paths,
439
+ };
440
+ }
441
+ throw stateLayoutConflict(
442
+ homeDir,
443
+ "新旧状态目录同时存在,拒绝自动合并 ownership",
444
+ paths.currentStateDir,
445
+ );
446
+ }
447
+ return {
448
+ kind: current.exists ? "current" : legacy.exists ? "legacy" : "fresh",
449
+ current,
450
+ legacy,
451
+ paths,
452
+ };
453
+ }
454
+
357
455
  function parseLockRecord(content) {
358
456
  const trimmed = content.trim();
359
457
  if (/^[1-9][0-9]*$/u.test(trimmed)) {
@@ -411,9 +509,13 @@ async function moveLockForCompare({ lockPath, expectedContent, stateDir, purpose
411
509
  throw error;
412
510
  }
413
511
 
414
- async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
415
- await mkdir(stateDir, { recursive: true });
416
- const stateSafety = await inspectAncestorPath(homeDir, stateDir);
512
+ async function acquireSyncLock({
513
+ homeDir,
514
+ stateDir,
515
+ lockName = "sync.lock",
516
+ beforeLockPublish,
517
+ }) {
518
+ let stateSafety = await inspectAncestorPath(homeDir, stateDir);
417
519
  if (!stateSafety.safe) {
418
520
  throw new SyncConflictError([
419
521
  {
@@ -421,15 +523,24 @@ async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
421
523
  component: "lock",
422
524
  assetName: "sync",
423
525
  skillName: "sync",
424
- relativePath: "sync.lock",
425
- targetPath: path.join(stateDir, "sync.lock"),
526
+ relativePath: lockName,
527
+ targetPath: path.join(stateDir, lockName),
426
528
  action: "conflict",
427
529
  reason: `${stateSafety.reason}:${stateSafety.path}`,
428
530
  },
429
531
  ]);
430
532
  }
533
+ await mkdir(stateDir, { recursive: true });
534
+ stateSafety = await inspectAncestorPath(homeDir, stateDir);
535
+ if (!stateSafety.safe) {
536
+ throw stateLayoutConflict(
537
+ homeDir,
538
+ `${stateSafety.reason}:${stateSafety.path}`,
539
+ stateDir,
540
+ );
541
+ }
431
542
 
432
- const lockPath = path.join(stateDir, "sync.lock");
543
+ const lockPath = path.join(stateDir, lockName);
433
544
  const owner = randomUUID();
434
545
  const recordContent = `${JSON.stringify({
435
546
  schemaVersion: 1,
@@ -437,7 +548,10 @@ async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
437
548
  owner,
438
549
  createdAt: new Date().toISOString(),
439
550
  })}\n`;
440
- const stagedLockPath = path.join(stateDir, `sync-lock-${owner}.tmp`);
551
+ const stagedLockPath = path.join(
552
+ stateDir,
553
+ `${lockName.replaceAll(".", "-")}-${owner}.tmp`,
554
+ );
441
555
  await writeFile(stagedLockPath, recordContent, { encoding: "utf8", flag: "wx" });
442
556
  if ((await readFile(stagedLockPath, "utf8")) !== recordContent) {
443
557
  await rm(stagedLockPath, { force: true });
@@ -445,7 +559,7 @@ async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
445
559
  }
446
560
 
447
561
  try {
448
- await transactionHooks.beforeLockPublish?.();
562
+ await beforeLockPublish?.();
449
563
  for (let attempt = 0; attempt < 3; attempt += 1) {
450
564
  try {
451
565
  await link(stagedLockPath, lockPath);
@@ -1303,6 +1417,111 @@ async function applyOperationsTransactional({
1303
1417
  }
1304
1418
  }
1305
1419
 
1420
+ async function migrateLegacyState({
1421
+ homeDir,
1422
+ paths,
1423
+ transactionHooks,
1424
+ }) {
1425
+ let syncLock = await acquireSyncLock({
1426
+ homeDir,
1427
+ stateDir: paths.legacyStateDir,
1428
+ beforeLockPublish: transactionHooks.beforeLockPublish,
1429
+ });
1430
+ let moved = false;
1431
+ try {
1432
+ const freshLayout = await inspectStateLayout(homeDir, paths);
1433
+ if (freshLayout.kind !== "legacy") {
1434
+ throw stateLayoutConflict(
1435
+ homeDir,
1436
+ "获取旧状态锁后布局发生变化,拒绝继续迁移",
1437
+ paths.legacyStateDir,
1438
+ );
1439
+ }
1440
+ const lockedSourceIdentity = freshLayout.legacy.identity;
1441
+ const legacyManifest = await inspectTarget(paths.legacyManifestPath);
1442
+ if (!legacyManifest.exists || !legacyManifest.regularFile) {
1443
+ throw stateLayoutConflict(
1444
+ homeDir,
1445
+ "旧状态目录缺少可信的普通 manifest.json",
1446
+ paths.legacyManifestPath,
1447
+ );
1448
+ }
1449
+ await readManifest(paths.legacyManifestPath);
1450
+ await transactionHooks.beforeStateMigrationRename?.();
1451
+ const beforeRename = await inspectStateLayout(homeDir, paths);
1452
+ if (
1453
+ beforeRename.kind !== "legacy" ||
1454
+ !sameStateDirectoryIdentity(
1455
+ lockedSourceIdentity,
1456
+ beforeRename.legacy.identity,
1457
+ )
1458
+ ) {
1459
+ throw stateLayoutConflict(
1460
+ homeDir,
1461
+ "旧状态目录身份在迁移提交前发生变化",
1462
+ paths.currentStateDir,
1463
+ );
1464
+ }
1465
+ try {
1466
+ await rename(paths.legacyStateDir, paths.currentStateDir);
1467
+ } catch (error) {
1468
+ if (error.code === "EXDEV") {
1469
+ throw stateLayoutConflict(
1470
+ homeDir,
1471
+ "新旧状态目录不在同一文件系统,拒绝退化为非原子复制",
1472
+ paths.currentStateDir,
1473
+ );
1474
+ }
1475
+ if (["EEXIST", "ENOTEMPTY"].includes(error.code)) {
1476
+ throw stateLayoutConflict(
1477
+ homeDir,
1478
+ "新状态目录在原子迁移前已出现",
1479
+ paths.currentStateDir,
1480
+ );
1481
+ }
1482
+ throw error;
1483
+ }
1484
+ moved = true;
1485
+ syncLock = {
1486
+ ...syncLock,
1487
+ stateDir: paths.currentStateDir,
1488
+ lockPath: path.join(paths.currentStateDir, "sync.lock"),
1489
+ };
1490
+ const migratedLayout = await inspectStateLayout(homeDir, paths);
1491
+ if (
1492
+ migratedLayout.kind !== "current" ||
1493
+ !sameStateDirectoryIdentity(
1494
+ lockedSourceIdentity,
1495
+ migratedLayout.current.identity,
1496
+ )
1497
+ ) {
1498
+ const error = new Error("状态目录迁移后的目标身份不一致");
1499
+ error.preserveEvidence = true;
1500
+ throw error;
1501
+ }
1502
+ const migratedManifest = await inspectTarget(paths.currentManifestPath);
1503
+ if (
1504
+ !migratedManifest.regularFile ||
1505
+ migratedManifest.hash !== legacyManifest.hash
1506
+ ) {
1507
+ const error = new Error("状态目录迁移后 manifest digest 不一致");
1508
+ error.preserveEvidence = true;
1509
+ throw error;
1510
+ }
1511
+ await transactionHooks.afterStateMigrationRename?.();
1512
+ return syncLock;
1513
+ } catch (error) {
1514
+ try {
1515
+ await releaseSyncLock(syncLock);
1516
+ } catch (releaseError) {
1517
+ error.releaseError = releaseError;
1518
+ error.preserveEvidence = true;
1519
+ }
1520
+ if (moved) error.stateMigrationCommitted = true;
1521
+ throw error;
1522
+ }
1523
+ }
1524
+
1306
1525
  export async function syncSkills({
1307
1526
  rootDir,
1308
1527
  homeDir,
@@ -1329,14 +1548,114 @@ export async function syncSkills({
1329
1548
  const resolvedRoot = path.resolve(rootDir);
1330
1549
  const resolvedHome = path.resolve(homeDir);
1331
1550
  await assertSafeHomeRoot(resolvedHome);
1332
- const manifestPath = path.join(resolvedHome, ".netpilot-skills", "manifest.json");
1333
- const stateDir = path.dirname(manifestPath);
1334
- const syncLock = apply
1335
- ? await acquireSyncLock({ homeDir: resolvedHome, stateDir, transactionHooks })
1336
- : null;
1551
+ const statePaths = resolveStatePaths(resolvedHome);
1552
+ let stateLayout = await inspectStateLayout(resolvedHome, statePaths);
1553
+ let stateDir =
1554
+ stateLayout.kind === "legacy"
1555
+ ? statePaths.legacyStateDir
1556
+ : statePaths.currentStateDir;
1557
+ let manifestPath =
1558
+ stateLayout.kind === "legacy"
1559
+ ? statePaths.legacyManifestPath
1560
+ : statePaths.currentManifestPath;
1561
+ let stateMigration =
1562
+ stateLayout.kind === "legacy"
1563
+ ? {
1564
+ action: "move",
1565
+ from: statePaths.legacyStateDir,
1566
+ to: statePaths.currentStateDir,
1567
+ }
1568
+ : null;
1569
+ let stateRepair =
1570
+ stateLayout.kind === "current-with-empty-legacy"
1571
+ ? {
1572
+ action: "remove-empty-legacy-state",
1573
+ target: statePaths.legacyStateDir,
1574
+ }
1575
+ : null;
1576
+ let layoutLock = null;
1577
+ let syncLock = null;
1337
1578
  let syncError = null;
1338
1579
 
1339
1580
  try {
1581
+ if (apply) {
1582
+ layoutLock = await acquireSyncLock({
1583
+ homeDir: resolvedHome,
1584
+ stateDir: statePaths.layoutLockDir,
1585
+ lockName: LAYOUT_LOCK_NAME,
1586
+ beforeLockPublish: transactionHooks.beforeLayoutLockPublish,
1587
+ });
1588
+ stateLayout = await inspectStateLayout(resolvedHome, statePaths);
1589
+ if (stateLayout.kind === "current-with-empty-legacy") {
1590
+ stateRepair = {
1591
+ action: "remove-empty-legacy-state",
1592
+ target: statePaths.legacyStateDir,
1593
+ };
1594
+ const expectedCurrentIdentity = stateLayout.current.identity;
1595
+ try {
1596
+ await rmdir(statePaths.legacyStateDir);
1597
+ } catch (error) {
1598
+ if (["ENOTEMPTY", "EEXIST"].includes(error.code)) {
1599
+ throw stateLayoutConflict(
1600
+ resolvedHome,
1601
+ "空旧状态目录在清理前出现内容,拒绝继续",
1602
+ statePaths.legacyStateDir,
1603
+ );
1604
+ }
1605
+ throw error;
1606
+ }
1607
+ const repairedLayout = await inspectStateLayout(
1608
+ resolvedHome,
1609
+ statePaths,
1610
+ );
1611
+ if (
1612
+ repairedLayout.kind !== "current" ||
1613
+ !sameStateDirectoryIdentity(
1614
+ expectedCurrentIdentity,
1615
+ repairedLayout.current.identity,
1616
+ )
1617
+ ) {
1618
+ throw stateLayoutConflict(
1619
+ resolvedHome,
1620
+ "清理旧状态残留后当前状态目录身份发生变化",
1621
+ statePaths.currentStateDir,
1622
+ );
1623
+ }
1624
+ stateLayout = repairedLayout;
1625
+ }
1626
+ if (stateLayout.kind === "legacy") {
1627
+ stateMigration = {
1628
+ action: "move",
1629
+ from: statePaths.legacyStateDir,
1630
+ to: statePaths.currentStateDir,
1631
+ };
1632
+ syncLock = await migrateLegacyState({
1633
+ homeDir: resolvedHome,
1634
+ paths: statePaths,
1635
+ transactionHooks,
1636
+ });
1637
+ } else {
1638
+ syncLock = await acquireSyncLock({
1639
+ homeDir: resolvedHome,
1640
+ stateDir: statePaths.currentStateDir,
1641
+ beforeLockPublish: transactionHooks.beforeLockPublish,
1642
+ });
1643
+ }
1644
+ const lockedLayout = await inspectStateLayout(
1645
+ resolvedHome,
1646
+ statePaths,
1647
+ );
1648
+ if (lockedLayout.kind !== "current") {
1649
+ throw stateLayoutConflict(
1650
+ resolvedHome,
1651
+ "获取同步锁后项目状态布局发生变化,拒绝继续",
1652
+ statePaths.currentStateDir,
1653
+ );
1654
+ }
1655
+ stateLayout = lockedLayout;
1656
+ stateDir = statePaths.currentStateDir;
1657
+ manifestPath = statePaths.currentManifestPath;
1658
+ }
1340
1659
  if (syncLock) await transactionHooks.afterLockAcquired?.();
1341
1660
  const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
1342
1661
  if (!stateSafety.safe) {
@@ -1468,7 +1787,12 @@ export async function syncSkills({
1468
1787
  const result = {
1469
1788
  mode: apply ? "apply" : "dry-run",
1470
1789
  homeDir: resolvedHome,
1471
- manifestPath,
1790
+ manifestPath: statePaths.currentManifestPath,
1791
+ ...(manifestPath !== statePaths.currentManifestPath
1792
+ ? { sourceManifestPath: manifestPath }
1793
+ : {}),
1794
+ ...(stateMigration ? { stateMigration } : {}),
1795
+ ...(stateRepair ? { stateRepair } : {}),
1472
1796
  operations,
1473
1797
  conflicts,
1474
1798
  };
@@ -1488,18 +1812,21 @@ export async function syncSkills({
1488
1812
  syncError = error;
1489
1813
  throw error;
1490
1814
  } finally {
1491
- if (syncLock) {
1815
+ let releaseFailure = null;
1816
+ for (const lock of [syncLock, layoutLock]) {
1817
+ if (!lock) continue;
1492
1818
  try {
1493
- await releaseSyncLock(syncLock);
1819
+ await releaseSyncLock(lock);
1494
1820
  } catch (releaseError) {
1495
1821
  if (syncError) {
1496
1822
  syncError.releaseError = releaseError;
1497
1823
  syncError.preserveEvidence = true;
1498
1824
  } else {
1499
- throw releaseError;
1825
+ releaseFailure ??= releaseError;
1500
1826
  }
1501
1827
  }
1502
1828
  }
1829
+ if (!syncError && releaseFailure) throw releaseFailure;
1503
1830
  }
1504
1831
  }
1505
1832
 
@@ -1518,6 +1845,16 @@ Claude Code 目标:~/.claude/skills(选择 --host claude 时默认只安装
1518
1845
  }
1519
1846
 
1520
1847
  function printPlan(result) {
1848
+ if (result.stateRepair) {
1849
+ console.log(
1850
+ `[remove-empty] state ${result.stateRepair.target}`,
1851
+ );
1852
+ }
1853
+ if (result.stateMigration) {
1854
+ console.log(
1855
+ `[move] state ${result.stateMigration.from} -> ${result.stateMigration.to}`,
1856
+ );
1857
+ }
1521
1858
  for (const operation of result.operations) {
1522
1859
  const reason = operation.reason ? `(${operation.reason})` : "";
1523
1860
  const component = operation.component === "skills" ? "skills" : operation.component;