flower-trellis 0.6.3 → 0.6.5

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 (51) hide show
  1. package/README.md +20 -1
  2. package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +2 -0
  3. package/enhancements/0.6/overrides/bundles/intent-routing.json +2 -1
  4. package/enhancements/0.6/overrides/bundles/task-reference-contract.json +24 -0
  5. package/enhancements/0.6/overrides/conflicts.json +33 -0
  6. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/patch.json +19 -0
  7. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-content.py +39 -0
  8. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-selector.py +38 -0
  9. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/patch.json +20 -0
  10. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-content.py +64 -0
  11. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-selector.py +2 -0
  12. package/enhancements/0.6/scripts/decision_log.py +29 -24
  13. package/enhancements/0.6/scripts/task_progress.py +54 -13
  14. package/enhancements/0.6/scripts/untracked_flow.py +13 -5
  15. package/enhancements/MANIFEST.json +9 -2
  16. package/package.json +4 -4
  17. package/src/assets/flower_session_start.py +195 -0
  18. package/src/assets/flower_telemetry_hook.py +98 -0
  19. package/src/builtin-plugins/skill-garden/content-adapter.js +15 -1
  20. package/src/cli.js +3 -1
  21. package/src/commands/init.js +36 -1
  22. package/src/commands/plugin-remote.js +118 -20
  23. package/src/commands/plugin.js +25 -3
  24. package/src/commands/self-check.js +20 -0
  25. package/src/commands/self-update.js +38 -3
  26. package/src/commands/telemetry.js +30 -1
  27. package/src/commands/uninstall.js +20 -0
  28. package/src/commands/update-check.js +19 -0
  29. package/src/commands/update.js +59 -8
  30. package/src/commands/worktree.js +114 -0
  31. package/src/lib/cli-args.js +10 -0
  32. package/src/lib/developer.js +3 -2
  33. package/src/lib/flower-assets.js +21 -6
  34. package/src/lib/platform-patch-adapters.js +46 -8
  35. package/src/lib/telemetry-context.js +275 -0
  36. package/src/lib/telemetry-files.js +144 -0
  37. package/src/lib/telemetry-operation.js +84 -0
  38. package/src/lib/telemetry-queue.js +310 -0
  39. package/src/lib/telemetry-sender.js +7 -0
  40. package/src/lib/telemetry.js +12 -361
  41. package/src/patches/bundles/flower-platform-integration.json +4 -1
  42. package/src/patches/platforms/claude/session-start-hooks/patch.json +28 -0
  43. package/src/patches/platforms/claude/telemetry-hooks/patch.json +59 -0
  44. package/src/patches/platforms/codex/session-start-hooks/patch.json +3 -2
  45. package/src/patches/platforms/codex/telemetry-hooks/patch.json +59 -0
  46. package/src/plugin/application-service.js +170 -53
  47. package/src/plugin/contracts.js +1 -0
  48. package/src/plugin/lock-reachability.js +37 -0
  49. package/src/plugin/resolver/dependency-resolver.js +58 -7
  50. package/src/plugin/schemas/project-files.js +1 -0
  51. package/src/plugin/sources/gitlab-provider.js +50 -9
@@ -21,26 +21,9 @@ import {
21
21
  normalizeContentSelection,
22
22
  selectContentSkillEntries,
23
23
  } from "./content-selection.js";
24
+ import { classifyLockReachability } from "./lock-reachability.js";
24
25
 
25
- /**
26
- * 计算 lock roots 可达的全部 Plugin。
27
- *
28
- * @param {import("./contracts.js").PluginLock} lock Plugin lock
29
- * @returns {Set<string>} 可达 Plugin ID
30
- */
31
- function reachableLockIds(lock) {
32
- const byId = new Map(lock.plugins.map((plugin) => [plugin.id, plugin]));
33
- const reachable = new Set();
34
- const visit = (id) => {
35
- if (reachable.has(id)) return;
36
- reachable.add(id);
37
- const plugin = byId.get(id);
38
- if (!plugin) return;
39
- Object.keys(plugin.dependencies).sort(compareUtf8).forEach(visit);
40
- };
41
- [...lock.roots].sort(compareUtf8).forEach(visit);
42
- return reachable;
43
- }
26
+ const SKILL_GARDEN_MIGRATION_OWNER_ID = "flower/skill-garden";
44
27
 
45
28
  /**
46
29
  * 汇总既有 Plugin state 的实际投影平台,供无显式选择的生命周期继续复用。
@@ -122,6 +105,94 @@ function standardContentPlan(graph) {
122
105
  };
123
106
  }
124
107
 
108
+ /**
109
+ * 校验冻结 Plugin 的 lock/state 对应关系与当前目标摘要。
110
+ *
111
+ * @param {string} projectRoot 项目根
112
+ * @param {import("./contracts.js").ResolvedPlugin} locked 冻结 lock entry
113
+ * @param {import("./contracts.js").PluginStateEntry} applied 冻结 state entry
114
+ * @returns {void}
115
+ */
116
+ function assertPreservedState(projectRoot, locked, applied) {
117
+ if (
118
+ applied.version !== locked.version ||
119
+ !contentSelectionsEqual(applied.contentSelection, locked.contentSelection)
120
+ ) {
121
+ throw new PluginRuntimeError(`冻结 Plugin 的 lock/state 不一致:${locked.id}`, {
122
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
123
+ path: locked.id,
124
+ });
125
+ }
126
+ for (const entry of applied.paths) {
127
+ const target = path.join(projectRoot, ...entry.path.split("/"));
128
+ const actual = entry.kind === "directory"
129
+ ? hashDirectoryIfExists(target)
130
+ : hashFileIfExists(target);
131
+ if (actual !== entry.hash) {
132
+ throw new PluginRuntimeError(`冻结 Plugin 目标摘要漂移:${entry.path}`, {
133
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
134
+ path: entry.path,
135
+ details: { expected: entry.hash, actual },
136
+ });
137
+ }
138
+ }
139
+ for (const patchEntry of applied.patches) {
140
+ const target = path.join(projectRoot, ...patchEntry.target.split("/"));
141
+ const actual = hashFileIfExists(target);
142
+ if (actual !== patchEntry.resultHash) {
143
+ throw new PluginRuntimeError(`冻结 Plugin Patch 目标摘要漂移:${patchEntry.target}`, {
144
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
145
+ path: patchEntry.target,
146
+ details: { expected: patchEntry.resultHash, actual },
147
+ });
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 判断两个项目内路径是否相同或存在父子关系。
154
+ *
155
+ * @param {string} left 左路径
156
+ * @param {string} right 右路径
157
+ * @returns {boolean} 是否相交
158
+ */
159
+ function pathsOverlap(left, right) {
160
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
161
+ }
162
+
163
+ /**
164
+ * 阻止活跃 Plugin 的计划改写冻结 Plugin 的受管目标。
165
+ *
166
+ * @param {import("./contracts.js").PluginStateEntry[]} preservedEntries 冻结 state entries
167
+ * @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 活跃计划片段
168
+ * @returns {void}
169
+ */
170
+ function assertNoPreservedTargetConflicts(preservedEntries, plan) {
171
+ const protectedTargets = preservedEntries.flatMap((plugin) => [
172
+ ...plugin.paths.map(({ path: target }) => ({ owner: plugin.id, target })),
173
+ ...plugin.patches.map(({ target }) => ({ owner: plugin.id, target })),
174
+ ]);
175
+ const activeTargets = [
176
+ ...(plan.mutations || []).map(({ owner, target }) => ({ owner, target })),
177
+ ...(plan.directoryClaims || []).map(({ owner, path: target }) => ({ owner, target })),
178
+ ...(plan.directoryRemovals || []).map(({ owner, path: target }) => ({ owner, target })),
179
+ ...(plan.patchMutations || []).map(({ owner, target }) => ({ owner, target })),
180
+ ];
181
+ for (const active of activeTargets) {
182
+ const protectedTarget = protectedTargets.find((entry) => pathsOverlap(active.target, entry.target));
183
+ if (!protectedTarget) continue;
184
+ throw new PluginRuntimeError(`活跃 Plugin 计划与冻结目标冲突:${active.target}`, {
185
+ code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
186
+ path: active.target,
187
+ details: {
188
+ activeOwner: active.owner,
189
+ preservedOwner: protectedTarget.owner,
190
+ preservedTarget: protectedTarget.target,
191
+ },
192
+ });
193
+ }
194
+ }
195
+
125
196
  /**
126
197
  * Flower Plugin 生命周期应用服务。
127
198
  */
@@ -156,7 +227,7 @@ export class PluginApplicationService {
156
227
  /**
157
228
  * 添加或更新一个直接 Plugin 声明并应用完整图。
158
229
  *
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 添加选项
230
+ * @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
231
  * @returns {object} 生命周期结果
161
232
  */
162
233
  add(options) {
@@ -188,6 +259,7 @@ export class PluginApplicationService {
188
259
  approvals: options.approvals || [],
189
260
  approvedDigests: options.approvedDigests,
190
261
  nonInteractive: options.nonInteractive,
262
+ preserveIds: options.preserveIds || [],
191
263
  onPreflight: options.onPreflight,
192
264
  });
193
265
  }
@@ -199,7 +271,7 @@ export class PluginApplicationService {
199
271
  * 其锁定包也已不可重放,逐个更新会在其它节点上撞 `已锁定 Plugin 包不可重放`。
200
272
  * 传入 `widen` 时按 `update: "all"` 解析,让全部节点都允许升级,一次性解开这种死锁。
201
273
  *
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] 更新选项
274
+ * @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
275
  * @returns {object} 生命周期结果
204
276
  */
205
277
  update(options = {}) {
@@ -280,6 +352,7 @@ export class PluginApplicationService {
280
352
  approvals: options.approvals || [],
281
353
  approvedDigests: options.approvedDigests,
282
354
  nonInteractive: options.nonInteractive,
355
+ preserveIds: options.preserveIds || [],
283
356
  onPreflight: options.onPreflight,
284
357
  });
285
358
  }
@@ -397,10 +470,10 @@ export class PluginApplicationService {
397
470
  }
398
471
  }
399
472
  if (lock) {
400
- const reachable = reachableLockIds(lock);
473
+ const { reachableIds } = classifyLockReachability(pluginsFile.plugins, lock);
401
474
  for (const id of [...lockById.keys()].sort(compareUtf8)) {
402
475
  if (options.id && id !== options.id) continue;
403
- if (!reachable.has(id)) {
476
+ if (!reachableIds.has(id)) {
404
477
  diagnostics.push({
405
478
  code: "verify.lock-orphan",
406
479
  path: id,
@@ -557,10 +630,39 @@ export class PluginApplicationService {
557
630
  const pluginsFile = validatePluginsFile(input.plugins);
558
631
  const previousLock = this.store.readLock();
559
632
  const previousState = this.store.readState();
633
+ const preservedIds = new Set(input.preserveIds || []);
634
+ const previousLockById = new Map((previousLock?.plugins || []).map((plugin) => [plugin.id, plugin]));
635
+ const previousStateById = new Map((previousState?.plugins || []).map((plugin) => [plugin.id, plugin]));
560
636
  const resolution = resolvePluginGraph(pluginsFile.plugins, this.registry, {
561
637
  lockedPlugins: previousLock?.plugins || [],
562
638
  update: input.update,
639
+ preserveIds: [...preservedIds],
563
640
  });
641
+ const resolvedIds = new Set(resolution.graph.plugins.map(({ id }) => id));
642
+ const preservedEntries = [];
643
+ for (const id of [...preservedIds].sort(compareUtf8)) {
644
+ const previousLocked = previousLockById.get(id);
645
+ const previousEntry = previousStateById.get(id);
646
+ if (!previousLocked || !resolvedIds.has(id)) {
647
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
648
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
649
+ path: id,
650
+ });
651
+ }
652
+ if (!previousEntry) {
653
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 state:${id}`, {
654
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
655
+ path: id,
656
+ });
657
+ }
658
+ assertPreservedState(this.projectRoot, previousLocked, previousEntry);
659
+ preservedEntries.push(previousEntry);
660
+ }
661
+ const activeGraph = {
662
+ roots: resolution.graph.roots.filter((id) => !preservedIds.has(id)),
663
+ plugins: resolution.graph.plugins.filter(({ id }) => !preservedIds.has(id)),
664
+ };
665
+ const activeSelected = resolution.selected.filter(({ id }) => !preservedIds.has(id));
564
666
  let projection = {
565
667
  mutations: [],
566
668
  payloads: new Map(),
@@ -569,47 +671,45 @@ export class PluginApplicationService {
569
671
  directoryRemovals: [],
570
672
  };
571
673
  let platformSelection = { platforms: [], targets: [] };
572
- if (resolution.graph.plugins.length > 0) {
674
+ if (activeGraph.plugins.length > 0) {
573
675
  const platforms = input.platforms.length > 0
574
676
  ? input.platforms
575
677
  : statePlatforms(previousState);
576
678
  platformSelection = this.platformDetector(this.projectRoot, platforms);
577
679
  projection = projectPluginContent({
578
680
  projectRoot: this.projectRoot,
579
- graph: resolution.graph,
580
- selected: resolution.selected,
681
+ graph: activeGraph,
682
+ selected: activeSelected,
581
683
  registry: this.registry,
582
684
  platformSelection,
583
685
  previousState,
584
686
  });
585
687
  }
586
- const preservedIds = new Set(input.preserveIds || []);
587
688
  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);
689
+ const projectedStateById = new Map(projection.state.plugins.map((plugin) => [plugin.id, plugin]));
690
+ projection.state.plugins = resolution.graph.plugins.map(({ id }) => {
691
+ if (preservedIds.has(id)) return structuredClone(previousStateById.get(id));
692
+ const projected = projectedStateById.get(id);
693
+ if (!projected) throw new Error(`Resolved Plugin 缺少投影 state:${id}`);
694
+ return projected;
695
+ });
696
+ resolution.graph.plugins = resolution.graph.plugins.map((plugin) => (
697
+ preservedIds.has(plugin.id)
698
+ ? structuredClone(previousLockById.get(plugin.id))
699
+ : plugin
700
+ ));
701
+ assertNoPreservedTargetConflicts(preservedEntries, {
702
+ mutations: projection.mutations,
703
+ directoryClaims: projection.directoryClaims,
704
+ directoryRemovals: projection.directoryRemovals,
705
+ });
706
+ }
707
+ if (
708
+ preservedIds.has(SKILL_GARDEN_MIGRATION_OWNER_ID) &&
709
+ !projection.state.migration &&
710
+ previousState?.migration
711
+ ) {
712
+ projection.state.migration = structuredClone(previousState.migration);
613
713
  }
614
714
 
615
715
  const previousDirectories = new Map();
@@ -619,7 +719,11 @@ export class PluginApplicationService {
619
719
  }
620
720
  }
621
721
  const directoryClaims = [];
622
- const desiredDirectoryPaths = new Set();
722
+ const desiredDirectoryPaths = new Set(
723
+ projection.state.plugins.flatMap((plugin) => plugin.paths
724
+ .filter(({ kind }) => kind === "directory")
725
+ .map(({ path: target }) => target)),
726
+ );
623
727
  const directoryOwners = new Map();
624
728
  for (const claim of projection.directoryClaims) {
625
729
  desiredDirectoryPaths.add(claim.path);
@@ -665,6 +769,7 @@ export class PluginApplicationService {
665
769
  if (desiredDirectoryPaths.has(entry.path)) continue;
666
770
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
667
771
  const currentHash = hashDirectoryIfExists(target);
772
+ if (currentHash === null) continue;
668
773
  if (currentHash !== entry.hash) {
669
774
  throw new PluginRuntimeError(`受管 Plugin 目录已被用户修改,拒绝删除:${entry.path}`, {
670
775
  code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
@@ -680,6 +785,7 @@ export class PluginApplicationService {
680
785
  if (desiredPaths.has(entry.path)) continue;
681
786
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
682
787
  const currentHash = hashFileIfExists(target);
788
+ if (currentHash === null) continue;
683
789
  if (currentHash !== entry.hash) {
684
790
  throw new PluginRuntimeError(`受管 Plugin 文件已被用户修改,拒绝删除:${entry.path}`, {
685
791
  code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
@@ -697,6 +803,12 @@ export class PluginApplicationService {
697
803
  });
698
804
  }
699
805
  }
806
+ if (preservedIds.size > 0) {
807
+ assertNoPreservedTargetConflicts(preservedEntries, {
808
+ mutations: removalMutations,
809
+ directoryRemovals,
810
+ });
811
+ }
700
812
 
701
813
  const patchPlanner = getPluginPatchPlanner();
702
814
  let patchResult = standardContentPlan(resolution.graph);
@@ -774,6 +886,11 @@ export class PluginApplicationService {
774
886
  });
775
887
  }
776
888
  }
889
+ if (preservedIds.size > 0) {
890
+ assertNoPreservedTargetConflicts(preservedEntries, {
891
+ patchMutations: patchResult.patchMutations,
892
+ });
893
+ }
777
894
  const grants = new Map(patchResult.grants.map(({ pluginId, grant }) => [pluginId, grant]));
778
895
  for (const plugin of resolution.graph.plugins) {
779
896
  const grant = grants.get(plugin.id) || (
@@ -118,6 +118,7 @@
118
118
  * @property {"gitlab"} type GitLab 来源
119
119
  * @property {string} reference GitLab project path
120
120
  * @property {string} [indexCommit] Marketplace 索引 commit
121
+ * @property {string} [indexPath] Marketplace 索引安全相对路径
121
122
  */
122
123
 
123
124
  /**
@@ -0,0 +1,37 @@
1
+ import { compareUtf8 } from "./stable-order.js";
2
+
3
+ /**
4
+ * 从当前直接声明出发,分类既有 lock 中的可达、孤立和缺失节点。
5
+ *
6
+ * lock roots 可能来自半清理的旧项目,不能作为活跃 Plugin 的事实入口。调用方应使用当前
7
+ * `plugins.json` 声明开始遍历,才能在不访问外部来源的前提下识别可冻结节点和孤立状态。
8
+ *
9
+ * @param {import("./contracts.js").ProjectPluginDeclaration[]} declarations 当前直接声明
10
+ * @param {import("./contracts.js").PluginLock|null} lock 既有 Plugin lock
11
+ * @returns {{reachableIds:Set<string>,orphanIds:Set<string>,missingIds:Set<string>}} lock 节点分类
12
+ */
13
+ export function classifyLockReachability(declarations, lock) {
14
+ const plugins = lock?.plugins || [];
15
+ const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
16
+ const visited = new Set();
17
+ const reachableIds = new Set();
18
+ const missingIds = new Set();
19
+
20
+ const visit = (id) => {
21
+ if (visited.has(id)) return;
22
+ visited.add(id);
23
+ const plugin = byId.get(id);
24
+ if (!plugin) {
25
+ missingIds.add(id);
26
+ return;
27
+ }
28
+ reachableIds.add(id);
29
+ Object.keys(plugin.dependencies).sort(compareUtf8).forEach(visit);
30
+ };
31
+
32
+ declarations.map(({ id }) => id).sort(compareUtf8).forEach(visit);
33
+ const orphanIds = new Set(
34
+ plugins.map(({ id }) => id).filter((id) => !reachableIds.has(id)).sort(compareUtf8),
35
+ );
36
+ return { reachableIds, orphanIds, missingIds };
37
+ }
@@ -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
  });
@@ -38,6 +38,7 @@ const sourceDescriptorSchema = {
38
38
  type: { const: "gitlab" },
39
39
  reference: { type: "string", format: "gitlab-project" },
40
40
  indexCommit: { type: "string", format: "git-commit" },
41
+ indexPath: SAFE_PATH_SCHEMA,
41
42
  },
42
43
  },
43
44
  {
@@ -20,6 +20,9 @@ import {
20
20
  } from "./remote-archive.js";
21
21
 
22
22
  const PROFILE_RANK = Object.freeze({ standard: 0, integration: 1, system: 2 });
23
+ const RD_GUIDE_SOURCE_ID = "rd-guide";
24
+ const RD_GUIDE_PROJECT = "digital-rd-governance/rd-guide";
25
+ const LEGACY_RD_GUIDE_MARKETPLACE_PATH = ".flower-marketplace/marketplace.json";
23
26
 
24
27
  /**
25
28
  * GitLab Marketplace Provider;网络阶段由 prepare() 显式触发。
@@ -178,6 +181,7 @@ export class GitLabSourceProvider {
178
181
  inspected.version,
179
182
  manifest,
180
183
  inspected.indexCommit,
184
+ inspected.indexPath,
181
185
  );
182
186
  if (lockedPlugin) this.#assertLockedInspectionMatches(lockedPlugin, candidate);
183
187
  return candidate;
@@ -191,6 +195,7 @@ export class GitLabSourceProvider {
191
195
  */
192
196
  async prepareLocked(plugin) {
193
197
  if (this.packageRoots.has(this.#key(plugin))) return;
198
+ const indexPath = this.#lockedMarketplacePath(plugin);
194
199
  const cached = this.#findCachedPackage(plugin);
195
200
  if (cached) {
196
201
  this.#registerLockedCandidate(plugin, cached.root, cached.manifest, "standard");
@@ -198,7 +203,7 @@ export class GitLabSourceProvider {
198
203
  }
199
204
  const raw = await this.client.readRawFile(
200
205
  this.source.project,
201
- this.source.marketplacePath,
206
+ indexPath,
202
207
  plugin.source.indexCommit,
203
208
  );
204
209
  let marketplace;
@@ -627,7 +632,7 @@ export class GitLabSourceProvider {
627
632
  *
628
633
  * @param {string} pluginId Plugin 本地 ID
629
634
  * @param {string} requestedVersion 请求版本
630
- * @returns {Promise<{entry:object,version:object,indexCommit:string}>} Marketplace 版本上下文
635
+ * @returns {Promise<{entry:object,version:object,indexCommit:string,indexPath:string}>} Marketplace 版本上下文
631
636
  */
632
637
  async #marketplaceVersionForInspection(pluginId, requestedVersion) {
633
638
  const marketplace = await this.prepareIndex();
@@ -639,7 +644,7 @@ export class GitLabSourceProvider {
639
644
  path: `${this.id}/${pluginId}`,
640
645
  });
641
646
  }
642
- return { entry, version, indexCommit: this.indexCommit };
647
+ return { entry, version, indexCommit: this.indexCommit, indexPath: this.source.marketplacePath };
643
648
  }
644
649
 
645
650
  /**
@@ -647,12 +652,13 @@ export class GitLabSourceProvider {
647
652
  *
648
653
  * @param {string} pluginId Plugin 本地 ID
649
654
  * @param {import("../contracts.js").ResolvedPlugin} plugin 已锁定 Plugin
650
- * @returns {Promise<{entry:object,version:object,indexCommit:string}>} Marketplace 版本上下文
655
+ * @returns {Promise<{entry:object,version:object,indexCommit:string,indexPath:string}>} Marketplace 版本上下文
651
656
  */
652
657
  async #lockedMarketplaceVersionForInspection(pluginId, plugin) {
658
+ const indexPath = this.#lockedMarketplacePath(plugin);
653
659
  const raw = await this.client.readRawFile(
654
660
  this.source.project,
655
- this.source.marketplacePath,
661
+ indexPath,
656
662
  plugin.source.indexCommit,
657
663
  );
658
664
  let marketplace;
@@ -683,7 +689,7 @@ export class GitLabSourceProvider {
683
689
  path: plugin.id,
684
690
  });
685
691
  }
686
- return { entry, version, indexCommit: plugin.source.indexCommit };
692
+ return { entry, version, indexCommit: plugin.source.indexCommit, indexPath };
687
693
  }
688
694
 
689
695
  /**
@@ -700,7 +706,8 @@ export class GitLabSourceProvider {
700
706
  lockedPlugin.commit.toLowerCase() !== candidate.commit ||
701
707
  lockedPlugin.integrity !== candidate.integrity ||
702
708
  lockedPlugin.source.reference !== candidate.source.reference ||
703
- lockedPlugin.source.indexCommit !== candidate.source.indexCommit
709
+ lockedPlugin.source.indexCommit !== candidate.source.indexCommit ||
710
+ this.#lockedMarketplacePath(lockedPlugin) !== candidate.source.indexPath
704
711
  ) {
705
712
  throw new PluginRuntimeError(`GitLab lock 与 Marketplace 不一致:${lockedPlugin.id}`, {
706
713
  code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
@@ -717,9 +724,17 @@ export class GitLabSourceProvider {
717
724
  * @param {object} version Marketplace 版本条目
718
725
  * @param {object} manifest 已校验 Plugin manifest
719
726
  * @param {string} [indexCommit] Marketplace index commit
727
+ * @param {string} [indexPath] Marketplace index path
720
728
  * @returns {import("../contracts.js").PluginCandidate} Provider 候选
721
729
  */
722
- #candidateFromMarketplaceVersion(canonicalId, entry, version, manifest, indexCommit = this.indexCommit) {
730
+ #candidateFromMarketplaceVersion(
731
+ canonicalId,
732
+ entry,
733
+ version,
734
+ manifest,
735
+ indexCommit = this.indexCommit,
736
+ indexPath = this.source.marketplacePath,
737
+ ) {
723
738
  const id = composeCanonicalPluginId(this.id, manifest.id);
724
739
  if (id !== canonicalId || manifest.version !== version.version) {
725
740
  throw new PluginRuntimeError(`远程 Plugin 身份与索引不一致:${canonicalId}@${version.version}`, {
@@ -741,6 +756,7 @@ export class GitLabSourceProvider {
741
756
  type: "gitlab",
742
757
  reference: this.#sourceReference(entry.source),
743
758
  indexCommit,
759
+ indexPath: assertSafePosixRelativePath(indexPath, "Marketplace index path"),
744
760
  },
745
761
  commit: version.commit.toLowerCase(),
746
762
  integrity: version.integrity,
@@ -825,7 +841,10 @@ export class GitLabSourceProvider {
825
841
  const candidate = {
826
842
  id: plugin.id,
827
843
  version: plugin.version,
828
- source: plugin.source,
844
+ source: {
845
+ ...plugin.source,
846
+ indexPath: this.#lockedMarketplacePath(plugin),
847
+ },
829
848
  commit: plugin.commit,
830
849
  integrity: plugin.integrity,
831
850
  manifest,
@@ -834,6 +853,28 @@ export class GitLabSourceProvider {
834
853
  this.#registerCandidate(candidate, root);
835
854
  }
836
855
 
856
+ /**
857
+ * 解析旧 lock 固定使用的 Marketplace 路径。
858
+ *
859
+ * 旧 RD Guide 独立 Plugin 只存在于历史 `.flower-marketplace` 索引;该映射同时限定来源
860
+ * ID、项目和 Plugin ID 族,避免对其它 GitLab Marketplace 做多路径探测或误判。
861
+ *
862
+ * @param {import("../contracts.js").ResolvedPlugin} plugin 已锁定 Plugin
863
+ * @returns {string} 安全的固定 Marketplace 索引路径
864
+ */
865
+ #lockedMarketplacePath(plugin) {
866
+ const indexPath = plugin.source.indexPath || (
867
+ this.id === RD_GUIDE_SOURCE_ID &&
868
+ this.source.project === RD_GUIDE_PROJECT &&
869
+ plugin.source.id === RD_GUIDE_SOURCE_ID &&
870
+ plugin.source.reference === RD_GUIDE_PROJECT &&
871
+ plugin.id.startsWith(`${RD_GUIDE_SOURCE_ID}/xhgj-`)
872
+ ? LEGACY_RD_GUIDE_MARKETPLACE_PATH
873
+ : this.source.marketplacePath
874
+ );
875
+ return assertSafePosixRelativePath(indexPath, "Marketplace index path");
876
+ }
877
+
837
878
  /**
838
879
  * 登记已准备固定包候选。
839
880
  *