@netpilot/skills 0.4.0 → 0.6.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.6.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.6.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.6.0",
4
4
  "description": "面向 Codex 的中文工程协作 skills 与可组合工作流",
5
5
  "author": {
6
6
  "name": "NetPilot"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.6.0
8
+
9
+ - 将用户级与项目级安装状态迁移到中性的 `.agents/.state/skills-installer`;旧 `.netpilot-skills` 在 apply 时经锁保护、目录身份复核后原子迁移,preview 仅报告计划。旧版客户端留下的精确空目录可安全自愈,真实双状态或目标冲突仍整批停止。
10
+ - 项目安装继续只写入 `.agents/skills`,不安装项目级 Agents;用户级默认仍安装 Codex Skills 与少量 Codex Agents。
11
+ - 与 `@netpilot/harness` 协调发布同一 `v0.6.0` 标签,便于在新电脑或新项目中识别兼容的一组安装器能力;两个包仍保持独立职责和发布契约。
12
+
7
13
  ## 0.4.0
8
14
 
9
15
  - 将工程与生产力方法扩展为 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netpilot/skills",
3
- "version": "0.4.0",
3
+ "version": "0.6.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;