@netpilot/skills 0.3.2 → 0.4.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.
Files changed (84) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +25 -9
  5. package/CHANGELOG.md +21 -0
  6. package/README.md +78 -112
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/codex/architecture-designer.toml +2 -1
  9. package/agents/codex/backend-reviewer.toml +3 -1
  10. package/agents/codex/frontend-reviewer.toml +3 -1
  11. package/agents/codex/test-verifier.toml +4 -1
  12. package/bin/netpilot-skills.mjs +130 -6
  13. package/docs/agent-authoring.md +15 -5
  14. package/package.json +1 -1
  15. package/scripts/sync.mjs +965 -99
  16. package/scripts/validate.mjs +68 -14
  17. package/skills/ask/SKILL.md +51 -47
  18. package/skills/ask/agents/openai.yaml +3 -3
  19. package/skills/code-review/SKILL.md +68 -52
  20. package/skills/code-review/agents/openai.yaml +2 -2
  21. package/skills/codebase-design/SKILL.md +87 -50
  22. package/skills/codebase-design/agents/openai.yaml +2 -2
  23. package/skills/codebase-design/references/deepening.md +60 -0
  24. package/skills/codebase-design/references/design-it-twice.md +54 -0
  25. package/skills/diagnosing-bugs/SKILL.md +124 -54
  26. package/skills/diagnosing-bugs/agents/openai.yaml +2 -2
  27. package/skills/diagnosing-bugs/scripts/hitl-loop.template.mjs +52 -0
  28. package/skills/domain-modeling/SKILL.md +65 -55
  29. package/skills/domain-modeling/agents/openai.yaml +2 -2
  30. package/skills/domain-modeling/references/adr-format.md +47 -0
  31. package/skills/domain-modeling/references/context-format.md +60 -0
  32. package/skills/domain-modeling/references/domain-docs.md +53 -0
  33. package/skills/grill-me/SKILL.md +13 -0
  34. package/skills/grill-me/agents/openai.yaml +6 -0
  35. package/skills/grill-with-docs/SKILL.md +16 -63
  36. package/skills/grill-with-docs/agents/openai.yaml +3 -3
  37. package/skills/grilling/SKILL.md +10 -54
  38. package/skills/grilling/agents/openai.yaml +2 -2
  39. package/skills/handoff/SKILL.md +24 -42
  40. package/skills/handoff/agents/openai.yaml +3 -3
  41. package/skills/implement/SKILL.md +18 -55
  42. package/skills/implement/agents/openai.yaml +3 -3
  43. package/skills/improve-codebase-architecture/SKILL.md +88 -0
  44. package/skills/improve-codebase-architecture/agents/openai.yaml +6 -0
  45. package/skills/improve-codebase-architecture/references/html-report.md +158 -0
  46. package/skills/prototype/SKILL.md +21 -53
  47. package/skills/prototype/agents/openai.yaml +2 -2
  48. package/skills/prototype/references/logic.md +87 -0
  49. package/skills/prototype/references/ui.md +108 -0
  50. package/skills/research/SKILL.md +9 -66
  51. package/skills/research/agents/openai.yaml +2 -2
  52. package/skills/resolving-merge-conflicts/SKILL.md +94 -0
  53. package/skills/resolving-merge-conflicts/agents/openai.yaml +6 -0
  54. package/skills/tdd/SKILL.md +30 -46
  55. package/skills/tdd/agents/openai.yaml +2 -2
  56. package/skills/tdd/references/mocking.md +70 -0
  57. package/skills/tdd/references/tests.md +95 -0
  58. package/skills/teach/SKILL.md +115 -47
  59. package/skills/teach/agents/openai.yaml +3 -3
  60. package/skills/teach/references/glossary-format.md +35 -10
  61. package/skills/teach/references/learning-record-format.md +41 -11
  62. package/skills/teach/references/mission-format.md +20 -17
  63. package/skills/teach/references/resources-format.md +34 -16
  64. package/skills/to-spec/SKILL.md +56 -51
  65. package/skills/to-spec/agents/openai.yaml +3 -3
  66. package/skills/to-tickets/SKILL.md +84 -45
  67. package/skills/to-tickets/agents/openai.yaml +3 -3
  68. package/skills/triage/SKILL.md +171 -0
  69. package/skills/triage/agents/openai.yaml +6 -0
  70. package/skills/triage/references/agent-brief.md +168 -0
  71. package/skills/triage/references/issue-tracker-github.md +42 -0
  72. package/skills/triage/references/issue-tracker-gitlab.md +42 -0
  73. package/skills/triage/references/issue-tracker-local.md +28 -0
  74. package/skills/triage/references/out-of-scope.md +113 -0
  75. package/skills/triage/references/project-config.md +57 -0
  76. package/skills/triage/references/triage-labels.md +13 -0
  77. package/skills/wayfinder/SKILL.md +158 -51
  78. package/skills/wayfinder/agents/openai.yaml +3 -3
  79. package/skills/writing-great-skills/SKILL.md +96 -54
  80. package/skills/writing-great-skills/agents/openai.yaml +3 -3
  81. package/skills/writing-great-skills/references/glossary.md +279 -0
  82. package/agents/codex/code-reader.toml +0 -11
  83. package/skills/grill/SKILL.md +0 -54
  84. package/skills/grill/agents/openai.yaml +0 -6
package/scripts/sync.mjs CHANGED
@@ -1,5 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { copyFile, lstat, mkdir, open, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ rmdir,
13
+ writeFile,
14
+ } from "node:fs/promises";
3
15
  import os from "node:os";
4
16
  import path from "node:path";
5
17
  import { fileURLToPath } from "node:url";
@@ -15,7 +27,15 @@ const CODEX_AGENT_DESTINATION = [".codex", "agents"];
15
27
 
16
28
  const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
17
29
  const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
30
+ const COMPONENT_ALIASES = Object.freeze({
31
+ "system-agents": "agents",
32
+ });
18
33
  const SUPPORT_FILE_NAMES = Object.freeze(["LICENSE", "THIRD_PARTY_NOTICES.md"]);
34
+ const MANAGED_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
35
+
36
+ function canonicalComponent(component) {
37
+ return COMPONENT_ALIASES[component] ?? component;
38
+ }
19
39
 
20
40
  export class SyncConflictError extends Error {
21
41
  constructor(conflicts) {
@@ -27,16 +47,30 @@ export class SyncConflictError extends Error {
27
47
 
28
48
  export function parseArgs(argv) {
29
49
  let apply = false;
50
+ let requestedMode = null;
30
51
  let homeDir = os.homedir();
31
- let hosts = [...ALL_HOSTS];
32
- let components = ["skills"];
52
+ let hosts = ["codex"];
53
+ let components = [...ALL_COMPONENTS];
54
+ let componentSpecified = false;
33
55
 
34
56
  for (let index = 0; index < argv.length; index += 1) {
35
57
  const argument = argv[index];
36
58
  if (argument === "--apply") {
59
+ if (requestedMode === "dry-run") {
60
+ throw new Error("--apply 与 --dry-run 不能同时使用");
61
+ }
62
+ requestedMode = "apply";
37
63
  apply = true;
38
64
  continue;
39
65
  }
66
+ if (argument === "--dry-run") {
67
+ if (requestedMode === "apply") {
68
+ throw new Error("--apply 与 --dry-run 不能同时使用");
69
+ }
70
+ requestedMode = "dry-run";
71
+ apply = false;
72
+ continue;
73
+ }
40
74
  if (argument === "--host") {
41
75
  const host = argv[index + 1];
42
76
  if (!host) throw new Error("--host 需要 codex、claude 或 all");
@@ -52,12 +86,15 @@ export function parseArgs(argv) {
52
86
  }
53
87
  if (argument === "--component") {
54
88
  const component = argv[index + 1];
55
- if (!component) throw new Error("--component 需要 skills、agents 或 all");
89
+ if (!component) {
90
+ throw new Error("--component 需要 skills、system-agents、agents 或 all");
91
+ }
56
92
  index += 1;
93
+ componentSpecified = true;
57
94
  if (component === "all") {
58
95
  components = [...ALL_COMPONENTS];
59
- } else if (ALL_COMPONENTS.includes(component)) {
60
- components = [component];
96
+ } else if (ALL_COMPONENTS.includes(canonicalComponent(component))) {
97
+ components = [canonicalComponent(component)];
61
98
  } else {
62
99
  throw new Error(`不支持的组件:${component}`);
63
100
  }
@@ -75,6 +112,10 @@ export function parseArgs(argv) {
75
112
  throw new Error(`未知参数:${argument}`);
76
113
  }
77
114
 
115
+ if (!componentSpecified && !hosts.includes("codex")) {
116
+ components = ["skills"];
117
+ }
118
+
78
119
  return { apply, homeDir, hosts, components };
79
120
  }
80
121
 
@@ -86,6 +127,35 @@ async function hashFile(filePath) {
86
127
  return sha256(await readFile(filePath));
87
128
  }
88
129
 
130
+ function projectCodexSkill(skillContent) {
131
+ const frontmatter = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u);
132
+ if (!frontmatter) return skillContent;
133
+
134
+ const newline = frontmatter[0].includes("\r\n") ? "\r\n" : "\n";
135
+ const omittedKeys = new Set(["argument-hint", "disable-model-invocation"]);
136
+ const projectedLines = [];
137
+ let omittingValue = false;
138
+
139
+ for (const line of frontmatter[1].split(/\r?\n/u)) {
140
+ const key = /^([a-z0-9-]+):(?:\s|$)/iu.exec(line)?.[1] ?? null;
141
+ if (key) {
142
+ omittingValue = omittedKeys.has(key);
143
+ if (!omittingValue) projectedLines.push(line);
144
+ continue;
145
+ }
146
+ if (omittingValue && (/^[ \t]/u.test(line) || line.trim() === "")) continue;
147
+ omittingValue = false;
148
+ projectedLines.push(line);
149
+ }
150
+
151
+ const projectedFrontmatter = [
152
+ "---",
153
+ ...projectedLines,
154
+ "---",
155
+ ].join(newline);
156
+ return projectedFrontmatter + skillContent.slice(frontmatter[0].length);
157
+ }
158
+
89
159
  async function readManifest(manifestPath) {
90
160
  try {
91
161
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
@@ -228,12 +298,407 @@ async function inspectAncestorPath(basePath, targetDirectory) {
228
298
  return { safe: true };
229
299
  }
230
300
 
301
+ async function inspectHomeRoot(homeDir) {
302
+ const resolvedHome = path.resolve(homeDir);
303
+ const parsed = path.parse(resolvedHome);
304
+ let currentPath = parsed.root;
305
+ const segments = path.relative(parsed.root, resolvedHome).split(path.sep).filter(Boolean);
306
+
307
+ for (const segment of segments) {
308
+ currentPath = path.join(currentPath, segment);
309
+ try {
310
+ const stats = await lstat(currentPath);
311
+ if (stats.isSymbolicLink()) {
312
+ const isHomeRoot = path.resolve(currentPath) === resolvedHome;
313
+ return {
314
+ safe: false,
315
+ path: currentPath,
316
+ reason: isHomeRoot
317
+ ? "home 根目录是 symlink 或 junction"
318
+ : "home 路径的父目录是 symlink 或 junction",
319
+ };
320
+ }
321
+ if (!stats.isDirectory()) {
322
+ return {
323
+ safe: false,
324
+ path: currentPath,
325
+ reason:
326
+ path.resolve(currentPath) === resolvedHome
327
+ ? "home 根路径不是目录"
328
+ : "home 路径的父路径不是目录",
329
+ };
330
+ }
331
+ } catch (error) {
332
+ if (error.code === "ENOENT") return { safe: true };
333
+ throw error;
334
+ }
335
+ }
336
+
337
+ return { safe: true };
338
+ }
339
+
340
+ async function assertSafeHomeRoot(homeDir) {
341
+ const homeSafety = await inspectHomeRoot(homeDir);
342
+ if (homeSafety.safe) return;
343
+ throw new SyncConflictError([
344
+ {
345
+ host: "state",
346
+ component: "home",
347
+ assetName: "home",
348
+ skillName: "home",
349
+ relativePath: ".",
350
+ targetPath: homeDir,
351
+ action: "conflict",
352
+ reason: `${homeSafety.reason}:${homeSafety.path}`,
353
+ },
354
+ ]);
355
+ }
356
+
357
+ function parseLockRecord(content) {
358
+ const trimmed = content.trim();
359
+ if (/^[1-9][0-9]*$/u.test(trimmed)) {
360
+ return { schemaVersion: 0, pid: Number(trimmed), owner: `legacy-${trimmed}` };
361
+ }
362
+ try {
363
+ const record = JSON.parse(trimmed);
364
+ if (
365
+ record.schemaVersion !== 1 ||
366
+ !Number.isSafeInteger(record.pid) ||
367
+ record.pid <= 0 ||
368
+ typeof record.owner !== "string" ||
369
+ record.owner.length < 8 ||
370
+ typeof record.createdAt !== "string"
371
+ ) {
372
+ return null;
373
+ }
374
+ return record;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ function isProcessAlive(pid) {
381
+ if (pid === process.pid) return true;
382
+ try {
383
+ process.kill(pid, 0);
384
+ return true;
385
+ } catch (error) {
386
+ if (error.code === "ESRCH" || error.code === "EINVAL") return false;
387
+ return true;
388
+ }
389
+ }
390
+
391
+ async function moveLockForCompare({ lockPath, expectedContent, stateDir, purpose }) {
392
+ const movedPath = path.join(stateDir, `${purpose}-${randomUUID()}.lock`);
393
+ try {
394
+ await rename(lockPath, movedPath);
395
+ } catch (error) {
396
+ if (error.code === "ENOENT") return null;
397
+ throw error;
398
+ }
399
+
400
+ const movedContent = await readFile(movedPath, "utf8");
401
+ if (movedContent === expectedContent) return movedPath;
402
+
403
+ try {
404
+ await restoreQuarantinedRemoval(lockPath, movedPath);
405
+ } catch (restoreError) {
406
+ restoreError.preserveEvidence = true;
407
+ throw restoreError;
408
+ }
409
+ const error = new Error(`同步锁在回收期间发生变化:${lockPath}`);
410
+ error.preserveEvidence = true;
411
+ throw error;
412
+ }
413
+
414
+ async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
415
+ await mkdir(stateDir, { recursive: true });
416
+ const stateSafety = await inspectAncestorPath(homeDir, stateDir);
417
+ if (!stateSafety.safe) {
418
+ throw new SyncConflictError([
419
+ {
420
+ host: "state",
421
+ component: "lock",
422
+ assetName: "sync",
423
+ skillName: "sync",
424
+ relativePath: "sync.lock",
425
+ targetPath: path.join(stateDir, "sync.lock"),
426
+ action: "conflict",
427
+ reason: `${stateSafety.reason}:${stateSafety.path}`,
428
+ },
429
+ ]);
430
+ }
431
+
432
+ const lockPath = path.join(stateDir, "sync.lock");
433
+ const owner = randomUUID();
434
+ const recordContent = `${JSON.stringify({
435
+ schemaVersion: 1,
436
+ pid: process.pid,
437
+ owner,
438
+ createdAt: new Date().toISOString(),
439
+ })}\n`;
440
+ const stagedLockPath = path.join(stateDir, `sync-lock-${owner}.tmp`);
441
+ await writeFile(stagedLockPath, recordContent, { encoding: "utf8", flag: "wx" });
442
+ if ((await readFile(stagedLockPath, "utf8")) !== recordContent) {
443
+ await rm(stagedLockPath, { force: true });
444
+ throw new Error(`同步锁记录暂存校验失败:${stagedLockPath}`);
445
+ }
446
+
447
+ try {
448
+ await transactionHooks.beforeLockPublish?.();
449
+ for (let attempt = 0; attempt < 3; attempt += 1) {
450
+ try {
451
+ await link(stagedLockPath, lockPath);
452
+ await rm(stagedLockPath).catch(() => {});
453
+ return { lockPath, recordContent, stateDir };
454
+ } catch (error) {
455
+ if (error.code !== "EEXIST") throw error;
456
+
457
+ let existingContent;
458
+ try {
459
+ existingContent = await readFile(lockPath, "utf8");
460
+ } catch (readError) {
461
+ if (readError.code === "ENOENT") continue;
462
+ throw readError;
463
+ }
464
+ const existingRecord = parseLockRecord(existingContent);
465
+ if (!existingRecord) {
466
+ throw new Error(
467
+ `同步锁格式无法验证:${lockPath}。确认没有安装进程后再手工删除该文件。`,
468
+ );
469
+ }
470
+ if (isProcessAlive(existingRecord.pid)) {
471
+ throw new Error(
472
+ `已有同步进程持有锁:${lockPath}(PID ${existingRecord.pid})`,
473
+ );
474
+ }
475
+
476
+ const stalePath = await moveLockForCompare({
477
+ lockPath,
478
+ expectedContent: existingContent,
479
+ stateDir,
480
+ purpose: "stale-sync-lock",
481
+ });
482
+ if (!stalePath) continue;
483
+ await rm(stalePath);
484
+ }
485
+ }
486
+
487
+ throw new Error(`无法安全获取同步锁:${lockPath}`);
488
+ } catch (error) {
489
+ await rm(stagedLockPath, { force: true }).catch(() => {});
490
+ throw error;
491
+ }
492
+ }
493
+
494
+ async function releaseSyncLock(lock) {
495
+ const ownedPath = await moveLockForCompare({
496
+ lockPath: lock.lockPath,
497
+ expectedContent: lock.recordContent,
498
+ stateDir: lock.stateDir,
499
+ purpose: "released-sync-lock",
500
+ });
501
+ if (!ownedPath) {
502
+ const error = new Error(`同步锁在释放前已消失:${lock.lockPath}`);
503
+ error.preserveEvidence = true;
504
+ throw error;
505
+ }
506
+ await rm(ownedPath);
507
+ }
508
+
509
+ function conflictForOperation(operation, reason) {
510
+ return new SyncConflictError([
511
+ {
512
+ ...operation,
513
+ action: "conflict",
514
+ reason,
515
+ },
516
+ ]);
517
+ }
518
+
519
+ async function assertSafeOperationTarget(operation, homeDir) {
520
+ const targetParent = path.dirname(operation.targetPath);
521
+ const ancestor = await inspectAncestorPath(homeDir, targetParent);
522
+ if (!ancestor.safe) {
523
+ throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
524
+ }
525
+
526
+ const realHome = await realpath(homeDir);
527
+ const realParent = await realpath(targetParent);
528
+ const relativeParent = path.relative(realHome, realParent);
529
+ if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) {
530
+ throw conflictForOperation(operation, `目标父目录解析到 home 之外:${realParent}`);
531
+ }
532
+ }
533
+
534
+ async function prepareSafeTargetParent(operation, homeDir) {
535
+ const targetParent = path.dirname(operation.targetPath);
536
+ const ancestor = await inspectAncestorPath(homeDir, targetParent);
537
+ if (!ancestor.safe) {
538
+ throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
539
+ }
540
+ await mkdir(targetParent, { recursive: true });
541
+ await assertSafeOperationTarget(operation, homeDir);
542
+ }
543
+
544
+ async function assertSameTransactionDevice(operation, stageRoot) {
545
+ const [stageStats, targetParentStats] = await Promise.all([
546
+ lstat(stageRoot),
547
+ lstat(path.dirname(operation.targetPath)),
548
+ ]);
549
+ if (stageStats.dev !== targetParentStats.dev) {
550
+ throw conflictForOperation(
551
+ operation,
552
+ "目标与事务 staging 不在同一文件系统;无法保证原子提交与回滚",
553
+ );
554
+ }
555
+ }
556
+
557
+ async function linkStagedFileNoOverwrite({ operation, stagedPath }) {
558
+ try {
559
+ await link(stagedPath, operation.targetPath);
560
+ } catch (error) {
561
+ if (error.code === "EEXIST") {
562
+ throw conflictForOperation(operation, "目标在最终提交前被创建");
563
+ }
564
+ if (error.code === "EXDEV") {
565
+ throw conflictForOperation(
566
+ operation,
567
+ "目标与事务 staging 不在同一文件系统;无法原子发布",
568
+ );
569
+ }
570
+ throw error;
571
+ }
572
+ }
573
+
231
574
  function manifestKey(host, component, assetName, relativePath) {
232
575
  if (component === "skills") return `${host}/${assetName}/${relativePath}`;
233
576
  if (component === "agents") return `${host}/agents/${assetName}/${relativePath}`;
234
577
  return `${host}/${assetName}/${relativePath}`;
235
578
  }
236
579
 
580
+ function resolveManagedTarget(manifestKeyValue, homeDir, stateDir) {
581
+ const segments = manifestKeyValue.split("/");
582
+ if (
583
+ segments.some(
584
+ (segment) =>
585
+ segment.length === 0 ||
586
+ segment === "." ||
587
+ segment === ".." ||
588
+ segment.includes("\\") ||
589
+ /[\u0000-\u001f:*?"<>|]/u.test(segment) ||
590
+ /[. ]$/u.test(segment),
591
+ )
592
+ ) {
593
+ return null;
594
+ }
595
+
596
+ if (
597
+ segments[0] === "state" &&
598
+ segments[1] === "notices" &&
599
+ segments.length === 3 &&
600
+ SUPPORT_FILE_NAMES.includes(segments[2])
601
+ ) {
602
+ const relativeSegments = segments.slice(2);
603
+ return {
604
+ host: "state",
605
+ component: "support",
606
+ assetName: "notices",
607
+ skillName: "notices",
608
+ relativePath: relativeSegments.join("/"),
609
+ targetPath: path.join(stateDir, "notices", ...relativeSegments),
610
+ pruneRoot: path.join(stateDir, "notices"),
611
+ };
612
+ }
613
+
614
+ if (segments[0] === "codex" && segments[1] === "agents") {
615
+ if (
616
+ segments.length !== 4 ||
617
+ !MANAGED_NAME_PATTERN.test(segments[2]) ||
618
+ segments[3] !== `${segments[2]}.toml`
619
+ ) {
620
+ return null;
621
+ }
622
+ const relativeSegments = segments.slice(3);
623
+ return {
624
+ host: "codex",
625
+ component: "agents",
626
+ assetName: segments[2],
627
+ skillName: segments[2],
628
+ relativePath: relativeSegments.join("/"),
629
+ targetPath: path.join(homeDir, ...CODEX_AGENT_DESTINATION, ...relativeSegments),
630
+ pruneRoot: path.join(homeDir, ...CODEX_AGENT_DESTINATION),
631
+ };
632
+ }
633
+
634
+ if (
635
+ segments[0] in HOST_DESTINATIONS &&
636
+ segments.length >= 3 &&
637
+ MANAGED_NAME_PATTERN.test(segments[1])
638
+ ) {
639
+ const relativeSegments = segments.slice(2);
640
+ return {
641
+ host: segments[0],
642
+ component: "skills",
643
+ assetName: segments[1],
644
+ skillName: segments[1],
645
+ relativePath: relativeSegments.join("/"),
646
+ targetPath: path.join(
647
+ homeDir,
648
+ ...HOST_DESTINATIONS[segments[0]],
649
+ segments[1],
650
+ ...relativeSegments,
651
+ ),
652
+ pruneRoot: path.join(homeDir, ...HOST_DESTINATIONS[segments[0]]),
653
+ };
654
+ }
655
+
656
+ return null;
657
+ }
658
+
659
+ function targetIdentity(targetPath) {
660
+ const resolved = path.resolve(targetPath);
661
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
662
+ }
663
+
664
+ function collectManifestTargetConflicts(manifest, homeDir, stateDir) {
665
+ const conflicts = [];
666
+ const targetOwners = new Map();
667
+
668
+ for (const key of Object.keys(manifest.files)) {
669
+ const target = resolveManagedTarget(key, homeDir, stateDir);
670
+ if (!target) {
671
+ conflicts.push({
672
+ host: "state",
673
+ component: "manifest",
674
+ assetName: key,
675
+ skillName: key,
676
+ relativePath: key,
677
+ targetPath: path.join(stateDir, "manifest.json"),
678
+ action: "conflict",
679
+ reason: `manifest key 非规范或不受支持:${key}`,
680
+ manifestKey: key,
681
+ });
682
+ continue;
683
+ }
684
+
685
+ const identity = targetIdentity(target.targetPath);
686
+ const existingKey = targetOwners.get(identity);
687
+ if (existingKey && existingKey !== key) {
688
+ conflicts.push({
689
+ ...target,
690
+ action: "conflict",
691
+ reason: `manifest key 目标冲突:${existingKey} 与 ${key}`,
692
+ manifestKey: key,
693
+ });
694
+ continue;
695
+ }
696
+ targetOwners.set(identity, key);
697
+ }
698
+
699
+ return conflicts;
700
+ }
701
+
237
702
  async function planFileOperation({
238
703
  host,
239
704
  component,
@@ -241,12 +706,15 @@ async function planFileOperation({
241
706
  skillName = assetName,
242
707
  relativePath,
243
708
  sourcePath,
709
+ sourceContent = null,
244
710
  targetPath,
245
711
  manifest,
246
712
  homeDir,
247
713
  }) {
248
714
  const key = manifestKey(host, component, assetName, relativePath);
249
- const sourceHash = await hashFile(sourcePath);
715
+ const sourceHash = sourceContent === null
716
+ ? await hashFile(sourcePath)
717
+ : sha256(Buffer.from(sourceContent, "utf8"));
250
718
  const ancestor = await inspectAncestorPath(homeDir, path.dirname(targetPath));
251
719
  const target = ancestor.safe
252
720
  ? await inspectTarget(targetPath)
@@ -282,6 +750,7 @@ async function planFileOperation({
282
750
  skillName,
283
751
  relativePath,
284
752
  sourcePath,
753
+ sourceContent,
285
754
  targetPath,
286
755
  sourceHash,
287
756
  targetHash: target.hash,
@@ -292,6 +761,42 @@ async function planFileOperation({
292
761
  };
293
762
  }
294
763
 
764
+ async function planRemovalOperation({
765
+ manifestKeyValue,
766
+ previousHash,
767
+ target,
768
+ homeDir,
769
+ }) {
770
+ const ancestor = await inspectAncestorPath(homeDir, path.dirname(target.targetPath));
771
+ const installed = ancestor.safe
772
+ ? await inspectTarget(target.targetPath)
773
+ : { exists: false, regularFile: false, hash: null };
774
+ let action = "remove";
775
+ let reason = installed.exists ? "源中已移除" : "源中和安装目录中均已不存在,仅清理记录";
776
+
777
+ if (!ancestor.safe) {
778
+ action = "conflict";
779
+ reason = `${ancestor.reason}:${ancestor.path}`;
780
+ } else if (installed.exists && !installed.regularFile) {
781
+ action = "conflict";
782
+ reason = "源中已移除,但目标路径不是普通文件";
783
+ } else if (installed.exists && installed.hash !== previousHash) {
784
+ action = "conflict";
785
+ reason = "源中已移除,但文件在上次同步后被修改";
786
+ }
787
+
788
+ return {
789
+ ...target,
790
+ sourcePath: null,
791
+ sourceHash: null,
792
+ targetHash: installed.hash,
793
+ previousHash,
794
+ action,
795
+ reason,
796
+ manifestKey: manifestKeyValue,
797
+ };
798
+ }
799
+
295
800
  async function collectChangedTargetConflicts(operations, homeDir) {
296
801
  const conflicts = [];
297
802
  for (const operation of operations) {
@@ -318,28 +823,227 @@ async function collectChangedTargetConflicts(operations, homeDir) {
318
823
  (!target.regularFile || target.hash !== operation.sourceHash)
319
824
  ) {
320
825
  conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
826
+ } else if (
827
+ operation.action === "remove" &&
828
+ ((operation.targetHash === null && target.exists) ||
829
+ (operation.targetHash !== null &&
830
+ (!target.regularFile || target.hash !== operation.targetHash)))
831
+ ) {
832
+ conflicts.push({ ...operation, action: "conflict", reason: "待移除目标在预检后发生变化" });
321
833
  }
322
834
  }
323
835
  return conflicts;
324
836
  }
325
837
 
326
- async function applyOperationsTransactional({ operations, homeDir, manifestPath }) {
327
- const stateDir = path.dirname(manifestPath);
328
- await mkdir(stateDir, { recursive: true });
329
- const stateSafety = await inspectAncestorPath(homeDir, stateDir);
330
- if (!stateSafety.safe) {
838
+ async function collectFinalTargetConflicts(operations, homeDir) {
839
+ const conflicts = [];
840
+ for (const operation of operations) {
841
+ if (operation.action === "conflict") continue;
842
+ const ancestor = await inspectAncestorPath(homeDir, path.dirname(operation.targetPath));
843
+ if (!ancestor.safe) {
844
+ conflicts.push({
845
+ ...operation,
846
+ action: "conflict",
847
+ reason: `${ancestor.reason}:${ancestor.path}`,
848
+ });
849
+ continue;
850
+ }
851
+
852
+ const target = await inspectTarget(operation.targetPath);
853
+ if (operation.action === "remove") {
854
+ if (target.exists) {
855
+ conflicts.push({
856
+ ...operation,
857
+ action: "conflict",
858
+ reason: "已移除目标在 manifest 提交前重新出现",
859
+ });
860
+ }
861
+ continue;
862
+ }
863
+ if (!target.regularFile || target.hash !== operation.sourceHash) {
864
+ conflicts.push({
865
+ ...operation,
866
+ action: "conflict",
867
+ reason: "目标在发布后、manifest 提交前发生变化",
868
+ });
869
+ }
870
+ }
871
+ return conflicts;
872
+ }
873
+
874
+ async function removeEmptyParentDirectories(startDirectory, stopDirectory) {
875
+ const stop = path.resolve(stopDirectory);
876
+ let current = path.resolve(startDirectory);
877
+
878
+ while (current !== stop) {
879
+ const relative = path.relative(stop, current);
880
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
881
+ throw new Error(`拒绝清理受管理根目录之外的目录:${current}`);
882
+ }
883
+
884
+ try {
885
+ await rmdir(current);
886
+ } catch (error) {
887
+ if (error.code === "ENOENT") {
888
+ current = path.dirname(current);
889
+ continue;
890
+ }
891
+ if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return;
892
+ throw error;
893
+ }
894
+ current = path.dirname(current);
895
+ }
896
+ }
897
+
898
+ export async function restoreQuarantinedRemoval(targetPath, backupPath) {
899
+ await mkdir(path.dirname(targetPath), { recursive: true });
900
+ try {
901
+ await link(backupPath, targetPath);
902
+ } catch (error) {
903
+ const restoreError = new Error(
904
+ `无法无覆盖地恢复隔离文件;隔离副本保留在:${backupPath}`,
905
+ { cause: error },
906
+ );
907
+ restoreError.preserveEvidence = true;
908
+ throw restoreError;
909
+ }
910
+ try {
911
+ await rm(backupPath);
912
+ } catch (error) {
913
+ const cleanupError = new Error(
914
+ `目标已恢复,但隔离副本未能清理:${backupPath}`,
915
+ { cause: error },
916
+ );
917
+ cleanupError.preserveEvidence = true;
918
+ throw cleanupError;
919
+ }
920
+ }
921
+
922
+ export async function quarantineManagedRemoval({
923
+ targetPath,
924
+ backupPath,
925
+ expectedHash,
926
+ }) {
927
+ await mkdir(path.dirname(backupPath), { recursive: true });
928
+ try {
929
+ await rename(targetPath, backupPath);
930
+ } catch (error) {
331
931
  throw new SyncConflictError([
332
932
  {
333
933
  host: "state",
334
- skillName: "manifest",
335
- relativePath: "manifest.json",
336
- targetPath: manifestPath,
934
+ component: "removal",
935
+ assetName: path.basename(targetPath),
936
+ skillName: path.basename(targetPath),
937
+ relativePath: path.basename(targetPath),
938
+ targetPath,
337
939
  action: "conflict",
338
- reason: `${stateSafety.reason}:${stateSafety.path}`,
940
+ reason: `无法原子隔离待移除文件:${error.message}`,
941
+ },
942
+ ]);
943
+ }
944
+
945
+ let actualHash;
946
+ try {
947
+ actualHash = await hashFile(backupPath);
948
+ } catch (error) {
949
+ try {
950
+ await restoreQuarantinedRemoval(targetPath, backupPath);
951
+ } catch (restoreError) {
952
+ restoreError.cause = error;
953
+ throw restoreError;
954
+ }
955
+ throw error;
956
+ }
957
+
958
+ if (actualHash !== expectedHash) {
959
+ try {
960
+ await restoreQuarantinedRemoval(targetPath, backupPath);
961
+ } catch (restoreError) {
962
+ const conflict = new SyncConflictError([
963
+ {
964
+ host: "state",
965
+ component: "removal",
966
+ assetName: path.basename(targetPath),
967
+ skillName: path.basename(targetPath),
968
+ relativePath: path.basename(targetPath),
969
+ targetPath,
970
+ action: "conflict",
971
+ reason: `待移除文件已变化且无法安全恢复;隔离副本保留在 ${backupPath}`,
972
+ },
973
+ ]);
974
+ conflict.cause = restoreError;
975
+ conflict.preserveEvidence = true;
976
+ throw conflict;
977
+ }
978
+ throw new SyncConflictError([
979
+ {
980
+ host: "state",
981
+ component: "removal",
982
+ assetName: path.basename(targetPath),
983
+ skillName: path.basename(targetPath),
984
+ relativePath: path.basename(targetPath),
985
+ targetPath,
986
+ action: "conflict",
987
+ reason: "待移除目标在最终原子隔离时发生变化",
339
988
  },
340
989
  ]);
341
990
  }
342
991
 
992
+ return { backupPath, actualHash };
993
+ }
994
+
995
+ async function rollbackAppliedOperation({ entry, stageRoot, homeDir }) {
996
+ const { operation, backupPath, published } = entry;
997
+ await prepareSafeTargetParent(operation, homeDir);
998
+ await assertSameTransactionDevice(operation, stageRoot);
999
+
1000
+ let publishedBackupPath = null;
1001
+ const target = await inspectTarget(operation.targetPath);
1002
+ if (published && target.exists) {
1003
+ if (!target.regularFile || target.hash !== operation.sourceHash) {
1004
+ const error = new Error(
1005
+ `回滚拒绝覆盖事务期间变化的目标:${operation.targetPath}`,
1006
+ );
1007
+ error.preserveEvidence = true;
1008
+ throw error;
1009
+ }
1010
+ publishedBackupPath = path.join(
1011
+ stageRoot,
1012
+ "rollback-current",
1013
+ ...operation.manifestKey.split("/"),
1014
+ );
1015
+ try {
1016
+ await quarantineManagedRemoval({
1017
+ targetPath: operation.targetPath,
1018
+ backupPath: publishedBackupPath,
1019
+ expectedHash: operation.sourceHash,
1020
+ });
1021
+ } catch (error) {
1022
+ error.preserveEvidence = true;
1023
+ throw error;
1024
+ }
1025
+ }
1026
+
1027
+ if (operation.action === "create") {
1028
+ if (publishedBackupPath) await rm(publishedBackupPath);
1029
+ return;
1030
+ }
1031
+
1032
+ if (backupPath) {
1033
+ await restoreQuarantinedRemoval(operation.targetPath, backupPath);
1034
+ }
1035
+ if (publishedBackupPath) await rm(publishedBackupPath);
1036
+ }
1037
+
1038
+ async function applyOperationsTransactional({
1039
+ operations,
1040
+ homeDir,
1041
+ manifestPath,
1042
+ expectedManifestHash,
1043
+ transactionHooks = {},
1044
+ }) {
1045
+ await assertSafeHomeRoot(homeDir);
1046
+ const stateDir = path.dirname(manifestPath);
343
1047
  const stagingDir = path.join(stateDir, "staging");
344
1048
  const stagingSafety = await inspectAncestorPath(homeDir, stagingDir);
345
1049
  if (!stagingSafety.safe) {
@@ -355,29 +1059,10 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
355
1059
  ]);
356
1060
  }
357
1061
 
358
- const lockPath = path.join(stateDir, "sync.lock");
359
- let lockHandle;
360
- try {
361
- lockHandle = await open(lockPath, "wx");
362
- } catch (error) {
363
- if (error.code === "EEXIST") throw new Error(`已有同步进程持有锁:${lockPath}`);
364
- throw error;
365
- }
366
- try {
367
- await lockHandle.writeFile(`${process.pid}\n`, "utf8");
368
- } catch (error) {
369
- await lockHandle.close();
370
- await rm(lockPath, { force: true });
371
- throw error;
372
- }
373
-
374
- const stageRoot = path.join(stateDir, "staging", randomUUID());
1062
+ const stageRoot = path.join(stagingDir, randomUUID());
375
1063
  const stagedPaths = new Map();
376
1064
  const applied = [];
377
1065
  const rollbackErrors = [];
378
- let manifestBackupPath = null;
379
- let manifestExisted = false;
380
- let manifestChanged = false;
381
1066
  let preserveStage = false;
382
1067
 
383
1068
  try {
@@ -408,90 +1093,205 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
408
1093
  if (operation.action !== "create" && operation.action !== "update") continue;
409
1094
  const stagedPath = path.join(stageRoot, "next", ...operation.manifestKey.split("/"));
410
1095
  await mkdir(path.dirname(stagedPath), { recursive: true });
411
- await copyFile(operation.sourcePath, stagedPath);
1096
+ if (operation.sourceContent === null) {
1097
+ await copyFile(operation.sourcePath, stagedPath);
1098
+ } else {
1099
+ await writeFile(stagedPath, operation.sourceContent, "utf8");
1100
+ }
412
1101
  if ((await hashFile(stagedPath)) !== operation.sourceHash) {
413
1102
  throw new Error(`暂存文件校验失败:${operation.sourcePath}`);
414
1103
  }
415
1104
  stagedPaths.set(operation.manifestKey, stagedPath);
416
1105
  }
417
1106
 
418
- const lockedManifest = await readManifest(manifestPath);
419
1107
  const manifestTarget = await inspectTarget(manifestPath);
420
1108
  if (manifestTarget.exists && !manifestTarget.regularFile) {
421
1109
  throw new Error(`同步 manifest 不是普通文件:${manifestPath}`);
422
1110
  }
423
- if (manifestTarget.exists) {
424
- manifestExisted = true;
425
- manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
426
- await mkdir(path.dirname(manifestBackupPath), { recursive: true });
427
- await copyFile(manifestPath, manifestBackupPath);
1111
+ if (manifestTarget.hash !== expectedManifestHash) {
1112
+ throw conflictForOperation(
1113
+ {
1114
+ host: "state",
1115
+ component: "manifest",
1116
+ assetName: "manifest",
1117
+ skillName: "manifest",
1118
+ relativePath: "manifest.json",
1119
+ targetPath: manifestPath,
1120
+ },
1121
+ "同步 manifest 在规划后发生变化",
1122
+ );
1123
+ }
1124
+ const lockedManifest = await readManifest(manifestPath);
1125
+ const lockedManifestConflicts = collectManifestTargetConflicts(
1126
+ lockedManifest,
1127
+ homeDir,
1128
+ stateDir,
1129
+ );
1130
+ if (lockedManifestConflicts.length > 0) {
1131
+ throw new SyncConflictError(lockedManifestConflicts);
428
1132
  }
429
1133
 
430
1134
  const changedTargetConflicts = await collectChangedTargetConflicts(operations, homeDir);
431
1135
  if (changedTargetConflicts.length > 0) throw new SyncConflictError(changedTargetConflicts);
432
1136
 
433
1137
  for (const operation of operations) {
434
- if (operation.action !== "create" && operation.action !== "update") continue;
1138
+ if (!["create", "update", "remove"].includes(operation.action)) continue;
1139
+ if (operation.action === "remove" && operation.targetHash === null) continue;
1140
+
1141
+ await prepareSafeTargetParent(operation, homeDir);
1142
+ await transactionHooks.beforeOperationCommit?.(operation);
1143
+ await assertSafeOperationTarget(operation, homeDir);
1144
+ await assertSameTransactionDevice(operation, stageRoot);
1145
+
435
1146
  let backupPath = null;
436
- if (operation.action === "update") {
1147
+ let entry = null;
1148
+ if (operation.action === "update" || operation.action === "remove") {
437
1149
  backupPath = path.join(stageRoot, "backups", ...operation.manifestKey.split("/"));
438
- await mkdir(path.dirname(backupPath), { recursive: true });
439
- await copyFile(operation.targetPath, backupPath);
440
- if ((await hashFile(backupPath)) !== operation.targetHash) {
441
- throw new Error(`目标备份校验失败:${operation.targetPath}`);
442
- }
1150
+ await quarantineManagedRemoval({
1151
+ targetPath: operation.targetPath,
1152
+ backupPath,
1153
+ expectedHash: operation.targetHash,
1154
+ });
1155
+ entry = { operation, backupPath, published: false };
1156
+ applied.push(entry);
1157
+ await transactionHooks.afterOperationQuarantine?.(operation);
443
1158
  }
1159
+ if (operation.action === "remove") continue;
444
1160
 
445
- applied.push({ operation, backupPath });
446
- await mkdir(path.dirname(operation.targetPath), { recursive: true });
447
- await copyFile(stagedPaths.get(operation.manifestKey), operation.targetPath);
1161
+ await assertSafeOperationTarget(operation, homeDir);
1162
+ await assertSameTransactionDevice(operation, stageRoot);
1163
+ await linkStagedFileNoOverwrite({
1164
+ operation,
1165
+ stagedPath: stagedPaths.get(operation.manifestKey),
1166
+ });
1167
+ if (!entry) {
1168
+ entry = { operation, backupPath: null, published: true };
1169
+ applied.push(entry);
1170
+ } else {
1171
+ entry.published = true;
1172
+ }
448
1173
  if ((await hashFile(operation.targetPath)) !== operation.sourceHash) {
449
1174
  throw new Error(`同步后校验失败:${operation.targetPath}`);
450
1175
  }
1176
+ await transactionHooks.afterOperationPublish?.(operation);
451
1177
  }
452
1178
 
453
1179
  const nextFiles = { ...lockedManifest.files };
454
- for (const operation of operations) nextFiles[operation.manifestKey] = operation.sourceHash;
1180
+ for (const operation of operations) {
1181
+ if (operation.action === "remove") {
1182
+ delete nextFiles[operation.manifestKey];
1183
+ } else {
1184
+ nextFiles[operation.manifestKey] = operation.sourceHash;
1185
+ }
1186
+ }
455
1187
  const nextManifest = {
456
1188
  schemaVersion: 1,
457
1189
  managedBy: "netpilot-skills",
458
1190
  updatedAt: new Date().toISOString(),
459
- files: Object.fromEntries(Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right))),
1191
+ files: Object.fromEntries(
1192
+ Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right)),
1193
+ ),
460
1194
  };
461
1195
  const stagedManifestPath = path.join(stageRoot, "next-manifest.json");
462
- await mkdir(path.dirname(stagedManifestPath), { recursive: true });
463
1196
  await writeFile(stagedManifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
464
1197
  JSON.parse(await readFile(stagedManifestPath, "utf8"));
465
- manifestChanged = true;
466
- await copyFile(stagedManifestPath, manifestPath);
1198
+ const manifestOperation = {
1199
+ host: "state",
1200
+ component: "manifest",
1201
+ assetName: "manifest",
1202
+ skillName: "manifest",
1203
+ relativePath: "manifest.json",
1204
+ targetPath: manifestPath,
1205
+ manifestKey: "state/manifest.json",
1206
+ targetHash: manifestTarget.hash,
1207
+ sourceHash: await hashFile(stagedManifestPath),
1208
+ action: manifestTarget.exists ? "update" : "create",
1209
+ };
1210
+ await prepareSafeTargetParent(manifestOperation, homeDir);
1211
+ await transactionHooks.beforeOperationCommit?.(manifestOperation);
1212
+ await assertSafeOperationTarget(manifestOperation, homeDir);
1213
+ await assertSameTransactionDevice(manifestOperation, stageRoot);
1214
+ const finalTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
1215
+ if (finalTargetConflicts.length > 0) {
1216
+ throw new SyncConflictError(finalTargetConflicts);
1217
+ }
1218
+
1219
+ let manifestEntry;
1220
+ if (manifestOperation.action === "update") {
1221
+ const manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
1222
+ await quarantineManagedRemoval({
1223
+ targetPath: manifestPath,
1224
+ backupPath: manifestBackupPath,
1225
+ expectedHash: manifestOperation.targetHash,
1226
+ });
1227
+ manifestEntry = {
1228
+ operation: manifestOperation,
1229
+ backupPath: manifestBackupPath,
1230
+ published: false,
1231
+ };
1232
+ applied.push(manifestEntry);
1233
+ await transactionHooks.afterOperationQuarantine?.(manifestOperation);
1234
+ }
1235
+ await assertSafeOperationTarget(manifestOperation, homeDir);
1236
+ await assertSameTransactionDevice(manifestOperation, stageRoot);
1237
+ await linkStagedFileNoOverwrite({
1238
+ operation: manifestOperation,
1239
+ stagedPath: stagedManifestPath,
1240
+ });
1241
+ if (!manifestEntry) {
1242
+ manifestEntry = {
1243
+ operation: manifestOperation,
1244
+ backupPath: null,
1245
+ published: true,
1246
+ };
1247
+ applied.push(manifestEntry);
1248
+ } else {
1249
+ manifestEntry.published = true;
1250
+ }
1251
+ if ((await hashFile(manifestPath)) !== manifestOperation.sourceHash) {
1252
+ throw new Error(`同步后校验失败:${manifestPath}`);
1253
+ }
467
1254
  JSON.parse(await readFile(manifestPath, "utf8"));
468
- } catch (error) {
469
- for (const { operation, backupPath } of [...applied].reverse()) {
470
- try {
471
- if (backupPath) {
472
- await copyFile(backupPath, operation.targetPath);
473
- } else {
474
- await rm(operation.targetPath, { force: true });
475
- }
476
- } catch (rollbackError) {
477
- rollbackErrors.push(`${operation.targetPath}: ${rollbackError.message}`);
478
- }
1255
+ await transactionHooks.afterOperationPublish?.(manifestOperation);
1256
+ const postManifestTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
1257
+ if (postManifestTargetConflicts.length > 0) {
1258
+ throw new SyncConflictError(postManifestTargetConflicts);
1259
+ }
1260
+ const committedManifest = await inspectTarget(manifestPath);
1261
+ if (
1262
+ !committedManifest.regularFile ||
1263
+ committedManifest.hash !== manifestOperation.sourceHash
1264
+ ) {
1265
+ throw conflictForOperation(
1266
+ manifestOperation,
1267
+ "manifest 在最终提交后发生变化",
1268
+ );
479
1269
  }
480
1270
 
481
- if (manifestChanged) {
1271
+ for (const operation of operations) {
1272
+ if (
1273
+ operation.action === "remove" &&
1274
+ operation.targetHash !== null &&
1275
+ operation.pruneRoot
1276
+ ) {
1277
+ await removeEmptyParentDirectories(
1278
+ path.dirname(operation.targetPath),
1279
+ operation.pruneRoot,
1280
+ );
1281
+ }
1282
+ }
1283
+ } catch (error) {
1284
+ if (error.preserveEvidence) preserveStage = true;
1285
+ for (const entry of [...applied].reverse()) {
482
1286
  try {
483
- if (manifestExisted) {
484
- await copyFile(manifestBackupPath, manifestPath);
485
- } else {
486
- await rm(manifestPath, { force: true });
487
- }
1287
+ await rollbackAppliedOperation({ entry, stageRoot, homeDir });
488
1288
  } catch (rollbackError) {
489
- rollbackErrors.push(`${manifestPath}: ${rollbackError.message}`);
1289
+ preserveStage = true;
1290
+ rollbackErrors.push(`${entry.operation.targetPath}: ${rollbackError.message}`);
490
1291
  }
491
1292
  }
492
1293
 
493
1294
  if (rollbackErrors.length > 0) {
494
- preserveStage = true;
495
1295
  throw new Error(
496
1296
  `同步失败且回滚不完整;暂存保留在 ${stageRoot}\n${rollbackErrors.join("\n")}`,
497
1297
  { cause: error },
@@ -499,11 +1299,6 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
499
1299
  }
500
1300
  throw error;
501
1301
  } finally {
502
- try {
503
- await lockHandle.close();
504
- } finally {
505
- await rm(lockPath, { force: true });
506
- }
507
1302
  if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
508
1303
  }
509
1304
  }
@@ -511,12 +1306,13 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
511
1306
  export async function syncSkills({
512
1307
  rootDir,
513
1308
  homeDir,
514
- hosts = [...ALL_HOSTS],
515
- components = ["skills"],
1309
+ hosts = ["codex"],
1310
+ components = [...ALL_COMPONENTS],
516
1311
  apply = false,
1312
+ transactionHooks = {},
517
1313
  }) {
518
1314
  const selectedHosts = [...new Set(hosts)];
519
- const selectedComponents = [...new Set(components)];
1315
+ const selectedComponents = [...new Set(components.map(canonicalComponent))];
520
1316
  for (const host of selectedHosts) {
521
1317
  if (!(host in HOST_DESTINATIONS)) throw new Error(`不支持的宿主:${host}`);
522
1318
  }
@@ -525,15 +1321,23 @@ export async function syncSkills({
525
1321
  }
526
1322
  if (
527
1323
  selectedComponents.includes("agents") &&
528
- (selectedHosts.length !== 1 || selectedHosts[0] !== "codex")
1324
+ !selectedHosts.includes("codex")
529
1325
  ) {
530
- throw new Error("Codex agents 组件只支持显式选择 --host codex");
1326
+ throw new Error("Codex agents 组件要求 --host 包含 codex");
531
1327
  }
532
1328
 
533
1329
  const resolvedRoot = path.resolve(rootDir);
534
1330
  const resolvedHome = path.resolve(homeDir);
1331
+ await assertSafeHomeRoot(resolvedHome);
535
1332
  const manifestPath = path.join(resolvedHome, ".netpilot-skills", "manifest.json");
536
1333
  const stateDir = path.dirname(manifestPath);
1334
+ const syncLock = apply
1335
+ ? await acquireSyncLock({ homeDir: resolvedHome, stateDir, transactionHooks })
1336
+ : null;
1337
+ let syncError = null;
1338
+
1339
+ try {
1340
+ if (syncLock) await transactionHooks.afterLockAcquired?.();
537
1341
  const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
538
1342
  if (!stateSafety.safe) {
539
1343
  throw new SyncConflictError([
@@ -561,6 +1365,12 @@ export async function syncSkills({
561
1365
  ]);
562
1366
  }
563
1367
  const manifest = await readManifest(manifestPath);
1368
+ const manifestConflicts = collectManifestTargetConflicts(
1369
+ manifest,
1370
+ resolvedHome,
1371
+ stateDir,
1372
+ );
1373
+ if (manifestConflicts.length > 0) throw new SyncConflictError(manifestConflicts);
564
1374
  const sourceFiles = selectedComponents.includes("skills")
565
1375
  ? await discoverSourceFiles(resolvedRoot)
566
1376
  : [];
@@ -581,6 +1391,10 @@ export async function syncSkills({
581
1391
  const hostRoot = path.join(resolvedHome, ...HOST_DESTINATIONS[host]);
582
1392
  for (const source of sourceFiles) {
583
1393
  const targetPath = path.join(hostRoot, source.skillName, ...source.relativePath.split("/"));
1394
+ const sourceContent =
1395
+ host === "codex" && source.relativePath === "SKILL.md"
1396
+ ? projectCodexSkill(await readFile(source.absolutePath, "utf8"))
1397
+ : null;
584
1398
  operations.push(await planFileOperation({
585
1399
  host,
586
1400
  component: "skills",
@@ -588,6 +1402,7 @@ export async function syncSkills({
588
1402
  skillName: source.skillName,
589
1403
  relativePath: source.relativePath,
590
1404
  sourcePath: source.absolutePath,
1405
+ sourceContent,
591
1406
  targetPath,
592
1407
  manifest,
593
1408
  homeDir: resolvedHome,
@@ -627,6 +1442,28 @@ export async function syncSkills({
627
1442
  }));
628
1443
  }
629
1444
 
1445
+ const desiredManifestKeys = new Set(operations.map((operation) => operation.manifestKey));
1446
+ for (const [key, previousHash] of Object.entries(manifest.files)) {
1447
+ if (desiredManifestKeys.has(key)) continue;
1448
+ const target = resolveManagedTarget(key, resolvedHome, stateDir);
1449
+ if (!target) continue;
1450
+ const selected =
1451
+ target.component === "support" ||
1452
+ (target.component === "skills" &&
1453
+ selectedComponents.includes("skills") &&
1454
+ selectedHosts.includes(target.host)) ||
1455
+ (target.component === "agents" &&
1456
+ selectedComponents.includes("agents") &&
1457
+ selectedHosts.includes("codex"));
1458
+ if (!selected) continue;
1459
+ operations.push(await planRemovalOperation({
1460
+ manifestKeyValue: key,
1461
+ previousHash,
1462
+ target,
1463
+ homeDir: resolvedHome,
1464
+ }));
1465
+ }
1466
+
630
1467
  const conflicts = operations.filter((operation) => operation.action === "conflict");
631
1468
  const result = {
632
1469
  mode: apply ? "apply" : "dry-run",
@@ -638,20 +1475,46 @@ export async function syncSkills({
638
1475
 
639
1476
  if (!apply) return result;
640
1477
  if (conflicts.length > 0) throw new SyncConflictError(conflicts);
641
- await applyOperationsTransactional({ operations, homeDir: resolvedHome, manifestPath });
1478
+ await applyOperationsTransactional({
1479
+ operations,
1480
+ homeDir: resolvedHome,
1481
+ manifestPath,
1482
+ expectedManifestHash: manifestTarget.hash,
1483
+ transactionHooks,
1484
+ });
642
1485
 
643
1486
  return result;
1487
+ } catch (error) {
1488
+ syncError = error;
1489
+ throw error;
1490
+ } finally {
1491
+ if (syncLock) {
1492
+ try {
1493
+ await releaseSyncLock(syncLock);
1494
+ } catch (releaseError) {
1495
+ if (syncError) {
1496
+ syncError.releaseError = releaseError;
1497
+ syncError.preserveEvidence = true;
1498
+ } else {
1499
+ throw releaseError;
1500
+ }
1501
+ }
1502
+ }
1503
+ }
644
1504
  }
645
1505
 
646
1506
  function printHelp() {
647
1507
  console.log(`用法:
648
- netpilot-skills install [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
649
- npm run sync -- [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
1508
+ netpilot-skills sync [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
1509
+ npm run sync -- [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
650
1510
 
651
- 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
652
- Codex 目标:~/.agents/skills
653
- Claude Code 目标:~/.claude/skills
654
- Codex agents 目标:~/.codex/agents(必须显式选择 --component agents 或 all)`);
1511
+ 底层 sync 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
1512
+ 默认目标:Codex skills 与 agents
1513
+ Codex Skills 目标:~/.agents/skills
1514
+ Codex 系统级(用户全局)Agents 目标:~/.codex/agents
1515
+ system-agents 是 agents 的语义化别名,二者安装内容与 manifest 所有权相同。
1516
+ Claude Code 目标:~/.claude/skills(选择 --host claude 时默认只安装 skills)。
1517
+ 选择 --host all 时,Skills 同步到 Codex 与 Claude,Agents 只同步到 Codex。`);
655
1518
  }
656
1519
 
657
1520
  function printPlan(result) {
@@ -661,12 +1524,14 @@ function printPlan(result) {
661
1524
  console.log(`[${operation.action}] ${operation.host} ${component} ${operation.assetName}/${operation.relativePath}${reason}`);
662
1525
  }
663
1526
  const counts = Object.fromEntries(
664
- ["create", "update", "unchanged", "conflict"].map((action) => [
1527
+ ["create", "update", "remove", "unchanged", "conflict"].map((action) => [
665
1528
  action,
666
1529
  result.operations.filter((operation) => operation.action === action).length,
667
1530
  ]),
668
1531
  );
669
- console.log(`汇总:新增 ${counts.create},更新 ${counts.update},不变 ${counts.unchanged},冲突 ${counts.conflict}`);
1532
+ console.log(
1533
+ `汇总:新增 ${counts.create},更新 ${counts.update},移除 ${counts.remove},不变 ${counts.unchanged},冲突 ${counts.conflict}`,
1534
+ );
670
1535
  if (result.mode === "dry-run") console.log("当前为 dry-run;确认计划后追加 --apply。 ");
671
1536
  }
672
1537
 
@@ -675,9 +1540,9 @@ async function main() {
675
1540
  printHelp();
676
1541
  return;
677
1542
  }
678
- const options = parseArgs(process.argv.slice(2));
679
- const rootDir = fileURLToPath(new URL("..", import.meta.url));
680
1543
  try {
1544
+ const options = parseArgs(process.argv.slice(2));
1545
+ const rootDir = fileURLToPath(new URL("..", import.meta.url));
681
1546
  const result = await syncSkills({ rootDir, ...options });
682
1547
  printPlan(result);
683
1548
  if (result.conflicts.length > 0) process.exitCode = 2;
@@ -690,7 +1555,8 @@ async function main() {
690
1555
  process.exitCode = 2;
691
1556
  return;
692
1557
  }
693
- throw error;
1558
+ console.error(error instanceof Error ? error.message : String(error));
1559
+ process.exitCode = 1;
694
1560
  }
695
1561
  }
696
1562