flower-trellis 0.6.3 → 0.6.4

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "syncedAt": "2026-08-26T01:20:11.756Z",
2
+ "syncedAt": "2026-08-28T18:19:48.301Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
4
  "sourceCommit": "124b592037eef542d7faf7ecb3be93b4bfd3aed0",
5
5
  "common": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,9 +70,9 @@
70
70
  "commit-and-tag-version": "^12.7.3"
71
71
  },
72
72
  "flowerReleaseNotes": {
73
- "version": "0.6.3",
73
+ "version": "0.6.4",
74
74
  "source": "CHANGELOG.md",
75
- "body": "### ✨ 新功能 Features\n\n* **plugin:** 对齐 RD Guide 的 Marketplace 包模型 ([e399d6e](https://github.com/SilentFlower/flower-trellis/commit/e399d6e70042256258431a4629f5efa15ba2f417))\n* **plugin:** 支持在非 Trellis 项目中管理通用 Skill ([0a9cd62](https://github.com/SilentFlower/flower-trellis/commit/0a9cd62ff3cd37971f5d65ea8500e4f51ed3a280))\n* **plugin:** 复用 GitLab 全局凭据 ([a3c6b16](https://github.com/SilentFlower/flower-trellis/commit/a3c6b1616b123ff0f7328ce5b2eace5eb631ae5b))\n* **plugin:** 支持 Marketplace Skill 子集选择 ([6406ccb](https://github.com/SilentFlower/flower-trellis/commit/6406ccbdbe982a885069a6a4af133fc132565a1c))\n\n\n### 🐛 修复 Bug Fixes\n\n* **plugin:** 加速 GitLab RD Guide Skill 清单读取 ([8bd1eac](https://github.com/SilentFlower/flower-trellis/commit/8bd1eac86ac32680d52c6fa715ece1b4c4ba99ef))",
75
+ "body": "### 🐛 修复 Bug Fixes\n\n* **plugin:** 支持离线冻结未更新的外部 Plugin ([6210cfa](https://github.com/SilentFlower/flower-trellis/commit/6210cfae8d459a3e7b105043e4ecbc23d1b5bed9))",
76
76
  "truncated": false
77
77
  },
78
78
  "optionalDependencies": {
@@ -537,12 +537,15 @@ export async function runPluginManagementCommand(parsed, ctx, options, output) {
537
537
  /**
538
538
  * 为生命周期命令登记所需的远程 Source Provider。
539
539
  *
540
- * @param {{parsed:object,projectRoot:string,options:object,registry:object,lock:object|null}} context 远程准备上下文
540
+ * @param {{parsed:object,projectRoot:string,options:object,registry:object,lock:object|null,preserveIds?:string[]}} context 远程准备上下文
541
541
  * @returns {Promise<void>} 登记完成
542
542
  */
543
- export async function registerRemotePluginSources({ parsed, projectRoot, options, registry, lock }) {
543
+ export async function registerRemotePluginSources({ parsed, projectRoot, options, registry, lock, preserveIds = [] }) {
544
+ const preserved = new Set(preserveIds);
544
545
  const remoteIds = new Set(["add", "update", "remove", "verify", "replay"].includes(parsed.command)
545
- ? (lock?.plugins || []).filter(({ source }) => ["gitlab", "github"].includes(source.type)).map(({ source }) => source.id)
546
+ ? (lock?.plugins || [])
547
+ .filter(({ id, source }) => !preserved.has(id) && ["gitlab", "github"].includes(source.type))
548
+ .map(({ source }) => source.id)
546
549
  : []);
547
550
  let sourceStore = null;
548
551
  let configuredSources = null;
@@ -584,6 +587,15 @@ export async function registerRemotePluginSources({ parsed, projectRoot, options
584
587
  remoteIds.add(simpleSourceOption);
585
588
  }
586
589
  }
590
+ if (parsed.command === "update" && parsed.pluginId?.includes("/")) {
591
+ for (const sourceId of collectUnregisteredDependencySources(
592
+ registry,
593
+ [parsed.pluginId],
594
+ preserveIds,
595
+ )) {
596
+ remoteIds.add(sourceId);
597
+ }
598
+ }
587
599
  remoteIds.delete("local");
588
600
  if (remoteIds.size === 0) return;
589
601
 
@@ -628,25 +640,57 @@ export async function registerRemotePluginSources({ parsed, projectRoot, options
628
640
  /**
629
641
  * 准备远程 Plugin 候选及固定 lock 缓存。
630
642
  *
631
- * @param {{parsed:object,canonicalId:string|null,registry:object,lock:object|null}} context 候选准备上下文
643
+ * @param {{parsed:object,canonicalId:string|null,registry:object,lock:object|null,preserveIds?:string[]}} context 候选准备上下文
632
644
  * @returns {Promise<void>} 准备完成
633
645
  */
634
- export async function prepareRemotePluginCandidates({ parsed, canonicalId, registry, lock }) {
646
+ export async function prepareRemotePluginCandidates({ parsed, canonicalId, registry, lock, preserveIds = [] }) {
635
647
  if (parsed.command === "add" && registry.has(parseCanonicalPluginId(canonicalId).sourceId)) {
636
- await prepareRemoteLock(registry, lock);
637
- await prepareRemoteClosure(registry, [canonicalId]);
648
+ await prepareRemoteLock(registry, lock, preserveIds);
649
+ await prepareRemoteClosure(registry, [canonicalId], preserveIds);
638
650
  } else if (parsed.command === "update") {
639
651
  // 更新单一 Plugin 时,解析器仍会 lock-first 重放完整图;先恢复全部远程固定包,
640
652
  // 再只为本轮显式更新目标加载新候选,避免顺带升级其它外部 Plugin。
641
- await prepareRemoteLock(registry, lock);
653
+ await prepareRemoteLock(registry, lock, preserveIds);
642
654
  const updateIds = canonicalId
643
655
  ? [canonicalId]
644
656
  : (lock?.plugins || []).filter(({ source }) => ["gitlab", "github"].includes(source.type)).map(({ id }) => id);
645
- await prepareRemoteClosure(registry, updateIds);
657
+ await prepareRemoteClosure(registry, updateIds, preserveIds);
646
658
  assertExternalVersionsNotReused(registry, lock, updateIds);
647
659
  } else if (["remove", "verify", "replay"].includes(parsed.command)) {
648
- await prepareRemoteLock(registry, lock);
660
+ await prepareRemoteLock(registry, lock, preserveIds);
661
+ }
662
+ }
663
+
664
+ /**
665
+ * 从当前可读取的活跃候选中收集尚未登记的依赖来源。
666
+ *
667
+ * 注册阶段不能准备远程候选,但 builtin/local 候选已经可读。先沿这些候选展开依赖,才能在
668
+ * 旧 lock 的同来源节点全部被冻结时,仍为本轮新增的非冻结依赖登记 Provider。
669
+ *
670
+ * @param {object} registry Provider 注册表
671
+ * @param {string[]} initialIds 活跃入口 canonical ID
672
+ * @param {string[]} [preserveIds] 不读取来源的冻结 Plugin ID
673
+ * @returns {string[]} 尚未登记的依赖来源 ID
674
+ */
675
+ function collectUnregisteredDependencySources(registry, initialIds, preserveIds = []) {
676
+ const preserved = new Set(preserveIds);
677
+ const pending = [...new Set(initialIds)];
678
+ const visited = new Set();
679
+ const sourceIds = new Set();
680
+ while (pending.length > 0) {
681
+ const id = pending.shift();
682
+ if (visited.has(id) || preserved.has(id)) continue;
683
+ visited.add(id);
684
+ const { sourceId } = parseCanonicalPluginId(id);
685
+ if (!registry.has(sourceId)) {
686
+ sourceIds.add(sourceId);
687
+ continue;
688
+ }
689
+ for (const candidate of registry.listCandidates(id)) {
690
+ pending.push(...Object.keys(candidate.manifest.dependencies || {}));
691
+ }
649
692
  }
693
+ return [...sourceIds].sort(compareUtf8);
650
694
  }
651
695
 
652
696
  /**
@@ -654,20 +698,21 @@ export async function prepareRemotePluginCandidates({ parsed, canonicalId, regis
654
698
  *
655
699
  * @param {object} registry Provider 注册表
656
700
  * @param {string[]} initialIds 初始 canonical ID
701
+ * @param {string[]} [preserveIds] 不读取来源的冻结 Plugin ID
657
702
  * @returns {Promise<void>} 准备完成
658
703
  */
659
- async function prepareRemoteClosure(registry, initialIds) {
704
+ async function prepareRemoteClosure(registry, initialIds, preserveIds = []) {
705
+ const preserved = new Set(preserveIds);
660
706
  const pending = [...new Set(initialIds)];
661
707
  const visited = new Set();
662
708
  while (pending.length > 0) {
663
709
  const id = pending.shift();
664
- if (visited.has(id)) continue;
710
+ if (visited.has(id) || preserved.has(id)) continue;
665
711
  visited.add(id);
666
712
  const { sourceId } = parseCanonicalPluginId(id);
667
713
  if (!registry.has(sourceId)) continue;
668
714
  const provider = registry.get(sourceId);
669
- if (typeof provider.prepare !== "function") continue;
670
- await provider.prepare(id);
715
+ if (typeof provider.prepare === "function") await provider.prepare(id);
671
716
  for (const candidate of provider.listCandidates(id)) {
672
717
  pending.push(...Object.keys(candidate.manifest.dependencies || {}));
673
718
  }
@@ -679,11 +724,17 @@ async function prepareRemoteClosure(registry, initialIds) {
679
724
  *
680
725
  * @param {object} registry Provider 注册表
681
726
  * @param {import("../plugin/contracts.js").PluginLock|null} lock 当前 lock
727
+ * @param {string[]} [preserveIds] 不读取来源的冻结 Plugin ID
682
728
  * @returns {Promise<void>} 恢复完成
683
729
  */
684
- async function prepareRemoteLock(registry, lock) {
730
+ async function prepareRemoteLock(registry, lock, preserveIds = []) {
731
+ const preserved = new Set(preserveIds);
685
732
  for (const plugin of lock?.plugins || []) {
686
- if (!["gitlab", "github"].includes(plugin.source.type) || !registry.has(plugin.source.id)) continue;
733
+ if (
734
+ preserved.has(plugin.id) ||
735
+ !["gitlab", "github"].includes(plugin.source.type) ||
736
+ !registry.has(plugin.source.id)
737
+ ) continue;
687
738
  const provider = registry.get(plugin.source.id);
688
739
  if (typeof provider.prepareLocked === "function") await provider.prepareLocked(plugin);
689
740
  else if (typeof provider.prepare === "function") await provider.prepare(plugin.id);
@@ -747,7 +747,7 @@ function pluginExitCode(error) {
747
747
  * 执行 Plugin 生命周期命令。
748
748
  *
749
749
  * @param {object} ctx cli-args.js 的执行上下文
750
- * @param {{cwd?:string,providers?:object[],output?:{log:(message:string)=>void,error:(message:string)=>void},interactive?:boolean,prompts?:object,confirmApproval?:(requests:object[])=>Promise<boolean>|boolean,compact?:boolean,onPreflight?:(result:object)=>void,trellisControlMode?:"materialized"|"restoring"}} [options] 测试、Provider、输出与交互确认注入
750
+ * @param {{cwd?:string,providers?:object[],output?:{log:(message:string)=>void,error:(message:string)=>void},interactive?:boolean,prompts?:object,confirmApproval?:(requests:object[])=>Promise<boolean>|boolean,compact?:boolean,preserveIds?:string[],onPreflight?:(result:object)=>void,trellisControlMode?:"materialized"|"restoring"}} [options] 测试、Provider、输出与交互确认注入
751
751
  * @returns {Promise<number>} 进程退出码
752
752
  */
753
753
  export async function plugin(ctx, options = {}) {
@@ -834,6 +834,7 @@ export async function plugin(ctx, options = {}) {
834
834
  options,
835
835
  registry,
836
836
  lock,
837
+ preserveIds: options.preserveIds || [],
837
838
  });
838
839
 
839
840
  if (parsed.command !== "add" && canonicalId && !canonicalId.includes("/")) {
@@ -889,7 +890,13 @@ export async function plugin(ctx, options = {}) {
889
890
  }));
890
891
  }
891
892
 
892
- await remoteRuntime?.prepareRemotePluginCandidates({ parsed, canonicalId, registry, lock });
893
+ await remoteRuntime?.prepareRemotePluginCandidates({
894
+ parsed,
895
+ canonicalId,
896
+ registry,
897
+ lock,
898
+ preserveIds: options.preserveIds || [],
899
+ });
893
900
 
894
901
  const service = new PluginApplicationService(ctx.target, { registry, store });
895
902
  let result;
@@ -906,6 +913,7 @@ export async function plugin(ctx, options = {}) {
906
913
  platforms: parsed.platforms,
907
914
  ...(parsed.contentSelection ? { contentSelection: parsed.contentSelection } : {}),
908
915
  dryRun: parsed.dryRun,
916
+ preserveIds: options.preserveIds || [],
909
917
  onPreflight: options.onPreflight,
910
918
  };
911
919
  result = parsed.dryRun ? service.add(addOptions) : await executeWithCapabilityApproval(
@@ -925,6 +933,7 @@ export async function plugin(ctx, options = {}) {
925
933
  platforms: parsed.platforms,
926
934
  ...(parsed.contentSelection ? { contentSelection: parsed.contentSelection } : {}),
927
935
  dryRun: parsed.dryRun,
936
+ preserveIds: options.preserveIds || [],
928
937
  onPreflight: options.onPreflight,
929
938
  };
930
939
  result = parsed.dryRun ? service.update(updateOptions) : await executeWithCapabilityApproval(
@@ -89,6 +89,13 @@ function pluginPlatformArgs(platforms) {
89
89
  */
90
90
  export async function replayPlugins(ctx, target, dryRun, compensationSnapshot = null) {
91
91
  const output = ctx.trellisControlQuiet ? SILENT_OUTPUT : console;
92
+ const store = new ProjectStore(target);
93
+ const lock = store.readLock();
94
+ const preserveIds = (lock?.plugins || [])
95
+ .filter(({ id, source }) => (
96
+ id !== SKILL_GARDEN_PLUGIN_ID && ["gitlab", "github"].includes(source.type)
97
+ ))
98
+ .map(({ id }) => id);
92
99
  const onPreflight = compensationSnapshot || ctx.trellisControlExtendSnapshot
93
100
  ? ({ plan }) => {
94
101
  const targets = [
@@ -100,7 +107,7 @@ export async function replayPlugins(ctx, target, dryRun, compensationSnapshot =
100
107
  }
101
108
  : undefined;
102
109
  if (ctx.enhance) {
103
- const declared = new ProjectStore(target).readPlugins().plugins
110
+ const declared = store.readPlugins().plugins
104
111
  .some(({ id }) => id === SKILL_GARDEN_PLUGIN_ID);
105
112
  const code = await plugin({
106
113
  ...ctx,
@@ -113,6 +120,7 @@ export async function replayPlugins(ctx, target, dryRun, compensationSnapshot =
113
120
  ],
114
121
  }, {
115
122
  skillGarden: { variant: ctx.variant, skills: ctx.skills },
123
+ preserveIds,
116
124
  compact: true,
117
125
  onPreflight,
118
126
  output,
@@ -122,7 +130,7 @@ export async function replayPlugins(ctx, target, dryRun, compensationSnapshot =
122
130
  }
123
131
 
124
132
  output.log("· --no-enhance:跳过 Skill-Garden,仅重放其它已声明 Plugin");
125
- const preserveSkillGarden = new ProjectStore(target).readLock()?.plugins
133
+ const preserveSkillGarden = lock?.plugins
126
134
  .some(({ id }) => id === SKILL_GARDEN_PLUGIN_ID) === true;
127
135
  const code = await plugin({
128
136
  ...ctx,
@@ -22,6 +22,8 @@ import {
22
22
  selectContentSkillEntries,
23
23
  } from "./content-selection.js";
24
24
 
25
+ const SKILL_GARDEN_MIGRATION_OWNER_ID = "flower/skill-garden";
26
+
25
27
  /**
26
28
  * 计算 lock roots 可达的全部 Plugin。
27
29
  *
@@ -122,6 +124,94 @@ function standardContentPlan(graph) {
122
124
  };
123
125
  }
124
126
 
127
+ /**
128
+ * 校验冻结 Plugin 的 lock/state 对应关系与当前目标摘要。
129
+ *
130
+ * @param {string} projectRoot 项目根
131
+ * @param {import("./contracts.js").ResolvedPlugin} locked 冻结 lock entry
132
+ * @param {import("./contracts.js").PluginStateEntry} applied 冻结 state entry
133
+ * @returns {void}
134
+ */
135
+ function assertPreservedState(projectRoot, locked, applied) {
136
+ if (
137
+ applied.version !== locked.version ||
138
+ !contentSelectionsEqual(applied.contentSelection, locked.contentSelection)
139
+ ) {
140
+ throw new PluginRuntimeError(`冻结 Plugin 的 lock/state 不一致:${locked.id}`, {
141
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
142
+ path: locked.id,
143
+ });
144
+ }
145
+ for (const entry of applied.paths) {
146
+ const target = path.join(projectRoot, ...entry.path.split("/"));
147
+ const actual = entry.kind === "directory"
148
+ ? hashDirectoryIfExists(target)
149
+ : hashFileIfExists(target);
150
+ if (actual !== entry.hash) {
151
+ throw new PluginRuntimeError(`冻结 Plugin 目标摘要漂移:${entry.path}`, {
152
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
153
+ path: entry.path,
154
+ details: { expected: entry.hash, actual },
155
+ });
156
+ }
157
+ }
158
+ for (const patchEntry of applied.patches) {
159
+ const target = path.join(projectRoot, ...patchEntry.target.split("/"));
160
+ const actual = hashFileIfExists(target);
161
+ if (actual !== patchEntry.resultHash) {
162
+ throw new PluginRuntimeError(`冻结 Plugin Patch 目标摘要漂移:${patchEntry.target}`, {
163
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
164
+ path: patchEntry.target,
165
+ details: { expected: patchEntry.resultHash, actual },
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 判断两个项目内路径是否相同或存在父子关系。
173
+ *
174
+ * @param {string} left 左路径
175
+ * @param {string} right 右路径
176
+ * @returns {boolean} 是否相交
177
+ */
178
+ function pathsOverlap(left, right) {
179
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
180
+ }
181
+
182
+ /**
183
+ * 阻止活跃 Plugin 的计划改写冻结 Plugin 的受管目标。
184
+ *
185
+ * @param {import("./contracts.js").PluginStateEntry[]} preservedEntries 冻结 state entries
186
+ * @param {{mutations?:Array<{owner:string,target:string}>,directoryClaims?:Array<{owner:string,path:string}>,directoryRemovals?:Array<{owner:string,path:string}>,patchMutations?:Array<{owner:string,target:string}>}} plan 活跃计划片段
187
+ * @returns {void}
188
+ */
189
+ function assertNoPreservedTargetConflicts(preservedEntries, plan) {
190
+ const protectedTargets = preservedEntries.flatMap((plugin) => [
191
+ ...plugin.paths.map(({ path: target }) => ({ owner: plugin.id, target })),
192
+ ...plugin.patches.map(({ target }) => ({ owner: plugin.id, target })),
193
+ ]);
194
+ const activeTargets = [
195
+ ...(plan.mutations || []).map(({ owner, target }) => ({ owner, target })),
196
+ ...(plan.directoryClaims || []).map(({ owner, path: target }) => ({ owner, target })),
197
+ ...(plan.directoryRemovals || []).map(({ owner, path: target }) => ({ owner, target })),
198
+ ...(plan.patchMutations || []).map(({ owner, target }) => ({ owner, target })),
199
+ ];
200
+ for (const active of activeTargets) {
201
+ const protectedTarget = protectedTargets.find((entry) => pathsOverlap(active.target, entry.target));
202
+ if (!protectedTarget) continue;
203
+ throw new PluginRuntimeError(`活跃 Plugin 计划与冻结目标冲突:${active.target}`, {
204
+ code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
205
+ path: active.target,
206
+ details: {
207
+ activeOwner: active.owner,
208
+ preservedOwner: protectedTarget.owner,
209
+ preservedTarget: protectedTarget.target,
210
+ },
211
+ });
212
+ }
213
+ }
214
+
125
215
  /**
126
216
  * Flower Plugin 生命周期应用服务。
127
217
  */
@@ -156,7 +246,7 @@ export class PluginApplicationService {
156
246
  /**
157
247
  * 添加或更新一个直接 Plugin 声明并应用完整图。
158
248
  *
159
- * @param {{id:string,version?:string,platforms?:string[],contentSelection?:import("./contracts.js").PluginContentSelection,dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} options 添加选项
249
+ * @param {{id:string,version?:string,platforms?:string[],contentSelection?:import("./contracts.js").PluginContentSelection,dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,preserveIds?:string[],onPreflight?:(result:object)=>void}} options 添加选项
160
250
  * @returns {object} 生命周期结果
161
251
  */
162
252
  add(options) {
@@ -188,6 +278,7 @@ export class PluginApplicationService {
188
278
  approvals: options.approvals || [],
189
279
  approvedDigests: options.approvedDigests,
190
280
  nonInteractive: options.nonInteractive,
281
+ preserveIds: options.preserveIds || [],
191
282
  onPreflight: options.onPreflight,
192
283
  });
193
284
  }
@@ -199,7 +290,7 @@ export class PluginApplicationService {
199
290
  * 其锁定包也已不可重放,逐个更新会在其它节点上撞 `已锁定 Plugin 包不可重放`。
200
291
  * 传入 `widen` 时按 `update: "all"` 解析,让全部节点都允许升级,一次性解开这种死锁。
201
292
  *
202
- * @param {{id?:string|null,version?:string,widen?:Map<string,string>|Record<string,string>,contentSelection?:import("./contracts.js").PluginContentSelection|null,platforms?:string[],dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} [options] 更新选项
293
+ * @param {{id?:string|null,version?:string,widen?:Map<string,string>|Record<string,string>,contentSelection?:import("./contracts.js").PluginContentSelection|null,platforms?:string[],dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,preserveIds?:string[],onPreflight?:(result:object)=>void}} [options] 更新选项
203
294
  * @returns {object} 生命周期结果
204
295
  */
205
296
  update(options = {}) {
@@ -280,6 +371,7 @@ export class PluginApplicationService {
280
371
  approvals: options.approvals || [],
281
372
  approvedDigests: options.approvedDigests,
282
373
  nonInteractive: options.nonInteractive,
374
+ preserveIds: options.preserveIds || [],
283
375
  onPreflight: options.onPreflight,
284
376
  });
285
377
  }
@@ -557,10 +649,39 @@ export class PluginApplicationService {
557
649
  const pluginsFile = validatePluginsFile(input.plugins);
558
650
  const previousLock = this.store.readLock();
559
651
  const previousState = this.store.readState();
652
+ const preservedIds = new Set(input.preserveIds || []);
653
+ const previousLockById = new Map((previousLock?.plugins || []).map((plugin) => [plugin.id, plugin]));
654
+ const previousStateById = new Map((previousState?.plugins || []).map((plugin) => [plugin.id, plugin]));
560
655
  const resolution = resolvePluginGraph(pluginsFile.plugins, this.registry, {
561
656
  lockedPlugins: previousLock?.plugins || [],
562
657
  update: input.update,
658
+ preserveIds: [...preservedIds],
563
659
  });
660
+ const resolvedIds = new Set(resolution.graph.plugins.map(({ id }) => id));
661
+ const preservedEntries = [];
662
+ for (const id of [...preservedIds].sort(compareUtf8)) {
663
+ const previousLocked = previousLockById.get(id);
664
+ const previousEntry = previousStateById.get(id);
665
+ if (!previousLocked || !resolvedIds.has(id)) {
666
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
667
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
668
+ path: id,
669
+ });
670
+ }
671
+ if (!previousEntry) {
672
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 state:${id}`, {
673
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
674
+ path: id,
675
+ });
676
+ }
677
+ assertPreservedState(this.projectRoot, previousLocked, previousEntry);
678
+ preservedEntries.push(previousEntry);
679
+ }
680
+ const activeGraph = {
681
+ roots: resolution.graph.roots.filter((id) => !preservedIds.has(id)),
682
+ plugins: resolution.graph.plugins.filter(({ id }) => !preservedIds.has(id)),
683
+ };
684
+ const activeSelected = resolution.selected.filter(({ id }) => !preservedIds.has(id));
564
685
  let projection = {
565
686
  mutations: [],
566
687
  payloads: new Map(),
@@ -569,47 +690,45 @@ export class PluginApplicationService {
569
690
  directoryRemovals: [],
570
691
  };
571
692
  let platformSelection = { platforms: [], targets: [] };
572
- if (resolution.graph.plugins.length > 0) {
693
+ if (activeGraph.plugins.length > 0) {
573
694
  const platforms = input.platforms.length > 0
574
695
  ? input.platforms
575
696
  : statePlatforms(previousState);
576
697
  platformSelection = this.platformDetector(this.projectRoot, platforms);
577
698
  projection = projectPluginContent({
578
699
  projectRoot: this.projectRoot,
579
- graph: resolution.graph,
580
- selected: resolution.selected,
700
+ graph: activeGraph,
701
+ selected: activeSelected,
581
702
  registry: this.registry,
582
703
  platformSelection,
583
704
  previousState,
584
705
  });
585
706
  }
586
- const preservedIds = new Set(input.preserveIds || []);
587
707
  if (preservedIds.size > 0) {
588
- for (const id of preservedIds) {
589
- const previousEntry = previousState?.plugins.find((plugin) => plugin.id === id);
590
- const nextIndex = projection.state.plugins.findIndex((plugin) => plugin.id === id);
591
- if (!previousEntry || nextIndex < 0) {
592
- throw new PluginRuntimeError(`冻结 Plugin 缺少既有 state:${id}`, {
593
- code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
594
- path: id,
595
- });
596
- }
597
- projection.state.plugins[nextIndex] = structuredClone(previousEntry);
598
- const previousLocked = previousLock?.plugins.find((plugin) => plugin.id === id);
599
- const graphIndex = resolution.graph.plugins.findIndex((plugin) => plugin.id === id);
600
- if (!previousLocked || graphIndex < 0) {
601
- throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
602
- code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
603
- path: id,
604
- });
605
- }
606
- resolution.graph.plugins[graphIndex] = structuredClone(previousLocked);
607
- }
608
- projection.mutations = projection.mutations.filter(({ owner }) => !preservedIds.has(owner));
609
- projection.directoryClaims = projection.directoryClaims.filter(({ owner }) => !preservedIds.has(owner));
610
- projection.directoryRemovals = projection.directoryRemovals
611
- .filter(({ owner }) => !preservedIds.has(owner));
612
- if (previousState?.migration) projection.state.migration = structuredClone(previousState.migration);
708
+ const projectedStateById = new Map(projection.state.plugins.map((plugin) => [plugin.id, plugin]));
709
+ projection.state.plugins = resolution.graph.plugins.map(({ id }) => {
710
+ if (preservedIds.has(id)) return structuredClone(previousStateById.get(id));
711
+ const projected = projectedStateById.get(id);
712
+ if (!projected) throw new Error(`Resolved Plugin 缺少投影 state:${id}`);
713
+ return projected;
714
+ });
715
+ resolution.graph.plugins = resolution.graph.plugins.map((plugin) => (
716
+ preservedIds.has(plugin.id)
717
+ ? structuredClone(previousLockById.get(plugin.id))
718
+ : plugin
719
+ ));
720
+ assertNoPreservedTargetConflicts(preservedEntries, {
721
+ mutations: projection.mutations,
722
+ directoryClaims: projection.directoryClaims,
723
+ directoryRemovals: projection.directoryRemovals,
724
+ });
725
+ }
726
+ if (
727
+ preservedIds.has(SKILL_GARDEN_MIGRATION_OWNER_ID) &&
728
+ !projection.state.migration &&
729
+ previousState?.migration
730
+ ) {
731
+ projection.state.migration = structuredClone(previousState.migration);
613
732
  }
614
733
 
615
734
  const previousDirectories = new Map();
@@ -619,7 +738,11 @@ export class PluginApplicationService {
619
738
  }
620
739
  }
621
740
  const directoryClaims = [];
622
- const desiredDirectoryPaths = new Set();
741
+ const desiredDirectoryPaths = new Set(
742
+ projection.state.plugins.flatMap((plugin) => plugin.paths
743
+ .filter(({ kind }) => kind === "directory")
744
+ .map(({ path: target }) => target)),
745
+ );
623
746
  const directoryOwners = new Map();
624
747
  for (const claim of projection.directoryClaims) {
625
748
  desiredDirectoryPaths.add(claim.path);
@@ -697,6 +820,12 @@ export class PluginApplicationService {
697
820
  });
698
821
  }
699
822
  }
823
+ if (preservedIds.size > 0) {
824
+ assertNoPreservedTargetConflicts(preservedEntries, {
825
+ mutations: removalMutations,
826
+ directoryRemovals,
827
+ });
828
+ }
700
829
 
701
830
  const patchPlanner = getPluginPatchPlanner();
702
831
  let patchResult = standardContentPlan(resolution.graph);
@@ -774,6 +903,11 @@ export class PluginApplicationService {
774
903
  });
775
904
  }
776
905
  }
906
+ if (preservedIds.size > 0) {
907
+ assertNoPreservedTargetConflicts(preservedEntries, {
908
+ patchMutations: patchResult.patchMutations,
909
+ });
910
+ }
777
911
  const grants = new Map(patchResult.grants.map(({ pluginId, grant }) => [pluginId, grant]));
778
912
  for (const plugin of resolution.graph.plugins) {
779
913
  const grant = grants.get(plugin.id) || (
@@ -3,6 +3,7 @@ import {
3
3
  PLUGIN_RUNTIME_ERROR_CODES,
4
4
  PluginRuntimeError,
5
5
  } from "../runtime-errors.js";
6
+ import { parseCanonicalPluginId } from "../schemas/shared.js";
6
7
  import { compareUtf8 } from "../stable-order.js";
7
8
  import { normalizeContentSelection } from "../content-selection.js";
8
9
 
@@ -41,6 +42,40 @@ function defaultCapabilityGrant(request) {
41
42
  };
42
43
  }
43
44
 
45
+ /**
46
+ * 从旧 lock 合成只参与依赖求解的冻结候选。
47
+ *
48
+ * 冻结候选不代表本地仍有可读取的 Plugin 包,因此 content 只放合法占位值;调用方必须在
49
+ * 内容投影前剥离该候选,并原样复用旧 lock/state。
50
+ *
51
+ * @param {import("../contracts.js").ResolvedPlugin} locked 已锁定 Plugin
52
+ * @returns {import("../contracts.js").PluginCandidate} 冻结候选
53
+ */
54
+ function createPreservedCandidate(locked) {
55
+ const { pluginId } = parseCanonicalPluginId(locked.id);
56
+ return {
57
+ id: locked.id,
58
+ version: locked.version,
59
+ source: structuredClone(locked.source),
60
+ commit: locked.commit,
61
+ integrity: locked.integrity,
62
+ manifest: {
63
+ schemaVersion: 1,
64
+ id: pluginId,
65
+ name: locked.id,
66
+ version: locked.version,
67
+ compatibility: structuredClone(locked.compatibility),
68
+ dependencies: structuredClone(locked.dependencies),
69
+ capabilities: {
70
+ profile: locked.capabilities.profile,
71
+ required: [...locked.capabilities.granted],
72
+ optional: [...locked.capabilities.denied],
73
+ },
74
+ content: { skills: [] },
75
+ },
76
+ };
77
+ }
78
+
44
79
  /**
45
80
  * 深复制约束映射。
46
81
  *
@@ -162,7 +197,7 @@ function stableTopologicalOrder(roots, selected) {
162
197
  *
163
198
  * @param {import("../contracts.js").ProjectPluginDeclaration[]} declarations 直接声明
164
199
  * @param {{listCandidates:(id:string)=>import("../contracts.js").PluginCandidate[]}} registry Source Registry
165
- * @param {{lockedPlugins?:import("../contracts.js").ResolvedPlugin[],update?:string[]|"all",grantCapabilities?:(request:import("../contracts.js").CapabilityRequest,candidate:import("../contracts.js").PluginCandidate)=>import("../contracts.js").CapabilityGrant}} [options] 解析选项
200
+ * @param {{lockedPlugins?:import("../contracts.js").ResolvedPlugin[],update?:string[]|"all",preserveIds?:string[],grantCapabilities?:(request:import("../contracts.js").CapabilityRequest,candidate:import("../contracts.js").PluginCandidate)=>import("../contracts.js").CapabilityGrant}} [options] 解析选项
166
201
  * @returns {{graph:import("../contracts.js").ResolvedGraph,selected:import("../contracts.js").PluginCandidate[],orphans:string[],constraints:Record<string,Array<{range:string,from:string}>>}} 解析结果
167
202
  */
168
203
  export function resolvePluginGraph(declarations, registry, options = {}) {
@@ -172,10 +207,20 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
172
207
  const updateIds = options.update === "all"
173
208
  ? new Set(lockedPlugins.map(({ id }) => id))
174
209
  : new Set(options.update || []);
210
+ const preservedIds = new Set(options.preserveIds || []);
175
211
  const grantCapabilities = options.grantCapabilities || defaultCapabilityGrant;
176
212
  const initialConstraints = new Map();
177
213
  const rootDeclarations = new Map(declarations.map((declaration) => [declaration.id, declaration]));
178
214
 
215
+ for (const id of [...preservedIds].sort(compareUtf8)) {
216
+ if (!lockedById.has(id)) {
217
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
218
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
219
+ path: id,
220
+ });
221
+ }
222
+ }
223
+
179
224
  for (const declaration of [...declarations].sort((left, right) => compareUtf8(left.id, right.id))) {
180
225
  const entries = initialConstraints.get(declaration.id) || [];
181
226
  entries.push({ range: declaration.version, from: "project" });
@@ -201,11 +246,15 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
201
246
  if (!nextId) return { selected, constraints };
202
247
 
203
248
  let candidates;
204
- try {
205
- candidates = registry.listCandidates(nextId);
206
- } catch (error) {
207
- if (error?.code !== PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND) throw error;
208
- candidates = [];
249
+ if (preservedIds.has(nextId)) {
250
+ candidates = [createPreservedCandidate(lockedById.get(nextId))];
251
+ } else {
252
+ try {
253
+ candidates = registry.listCandidates(nextId);
254
+ } catch (error) {
255
+ if (error?.code !== PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND) throw error;
256
+ candidates = [];
257
+ }
209
258
  }
210
259
  if (candidates.length === 0) {
211
260
  throw new PluginRuntimeError(`找不到 Plugin 依赖:${nextId}`, {
@@ -298,7 +347,9 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
298
347
  integrity: candidate.integrity,
299
348
  dependencies,
300
349
  compatibility: candidate.manifest.compatibility,
301
- capabilities: grantCapabilities(candidate.manifest.capabilities, candidate),
350
+ capabilities: preservedIds.has(id)
351
+ ? structuredClone(lockedById.get(id).capabilities)
352
+ : grantCapabilities(candidate.manifest.capabilities, candidate),
302
353
  ...(contentSelection ? { contentSelection } : {}),
303
354
  };
304
355
  });