flower-trellis 0.6.4 → 0.6.6

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 (54) 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 +37 -4
  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/overrides/patches/skills/trellis-brainstorm/planning-handoff/content.md +2 -4
  13. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/planning-contract-content.md +1 -1
  14. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-content.md +2 -2
  15. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/review-content.md +2 -2
  16. package/enhancements/0.6/scripts/decision_log.py +29 -24
  17. package/enhancements/0.6/scripts/task_progress.py +54 -13
  18. package/enhancements/0.6/scripts/untracked_flow.py +13 -5
  19. package/enhancements/MANIFEST.json +9 -2
  20. package/package.json +4 -4
  21. package/src/assets/flower_session_start.py +195 -0
  22. package/src/assets/flower_telemetry_hook.py +98 -0
  23. package/src/builtin-plugins/skill-garden/content-adapter.js +15 -1
  24. package/src/cli.js +3 -1
  25. package/src/commands/init.js +36 -1
  26. package/src/commands/plugin-remote.js +61 -14
  27. package/src/commands/plugin.js +14 -1
  28. package/src/commands/self-check.js +20 -0
  29. package/src/commands/self-update.js +38 -3
  30. package/src/commands/telemetry.js +30 -1
  31. package/src/commands/uninstall.js +20 -0
  32. package/src/commands/update-check.js +19 -0
  33. package/src/commands/update.js +52 -9
  34. package/src/commands/worktree.js +114 -0
  35. package/src/lib/cli-args.js +10 -0
  36. package/src/lib/developer.js +3 -2
  37. package/src/lib/flower-assets.js +21 -6
  38. package/src/lib/platform-patch-adapters.js +46 -8
  39. package/src/lib/telemetry-context.js +275 -0
  40. package/src/lib/telemetry-files.js +144 -0
  41. package/src/lib/telemetry-operation.js +84 -0
  42. package/src/lib/telemetry-queue.js +310 -0
  43. package/src/lib/telemetry-sender.js +7 -0
  44. package/src/lib/telemetry.js +12 -361
  45. package/src/patches/bundles/flower-platform-integration.json +4 -1
  46. package/src/patches/platforms/claude/session-start-hooks/patch.json +28 -0
  47. package/src/patches/platforms/claude/telemetry-hooks/patch.json +59 -0
  48. package/src/patches/platforms/codex/session-start-hooks/patch.json +3 -2
  49. package/src/patches/platforms/codex/telemetry-hooks/patch.json +59 -0
  50. package/src/plugin/application-service.js +5 -22
  51. package/src/plugin/contracts.js +1 -0
  52. package/src/plugin/lock-reachability.js +37 -0
  53. package/src/plugin/schemas/project-files.js +1 -0
  54. package/src/plugin/sources/gitlab-provider.js +50 -9
@@ -1,367 +1,18 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { flowerConfigDirectory } from "../plugin/sources/user-source-store.js";
5
- import { ProjectStore } from "../plugin/state/project-store.js";
6
- import { SKILL_GARDEN_PLUGIN_ID } from "../builtin-plugins/skill-garden/provider.js";
7
- import { readGitDeveloper } from "./developer.js";
8
- import { readManifest, readUpdateCheck } from "./manifest.js";
9
- import { flowerVersion, trellisVersion } from "./versions.js";
1
+ import { queueLegacyTelemetry } from "./telemetry-queue.js";
10
2
 
11
- /** Flower 遥测接收地址。主机名与 `/api/flower-trellis` 路由前缀都由服务端契约固定,缺一不可。 */
12
- export const FLOWER_TELEMETRY_ENDPOINT = "https://ai-api.hub.flower-cli.com/api/flower-trellis/telemetry";
3
+ // 对外保留旧模块入口;队列只依赖 context,避免兼容入口反向形成循环。
4
+ export {
5
+ FLOWER_TELEMETRY_ENDPOINT, telemetryStatePath, readTelemetryState,
6
+ setTelemetryEnabled, buildTelemetryPayload,
7
+ } from "./telemetry-context.js";
13
8
 
14
- const TELEMETRY_SCHEMA_VERSION = 1;
15
- /** 遥测请求保留 10 秒公网预算,避免慢速 TLS 建连或服务端响应被过早中止。 */
16
- const DEFAULT_TIMEOUT_MS = 10000;
17
- const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
18
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
- const EVENTS = new Set(["version_check", "init_completed", "update_completed"]);
20
-
21
- /**
22
- * 规范化可上报的开发者名称。
23
- *
24
- * @param {unknown} value 原始名称
25
- * @returns {string|null} 有效名称,无法使用时返回 null
26
- */
27
- function normalizeDeveloperName(value) {
28
- if (typeof value !== "string") return null;
29
- const name = value.trim();
30
- return name && name.length <= 100 ? name : null;
31
- }
32
-
33
- /**
34
- * 返回 Flower 用户级遥测状态文件路径。
35
- *
36
- * @param {NodeJS.ProcessEnv} [env] 环境变量
37
- * @returns {string} 状态文件绝对路径
38
- */
39
- export function telemetryStatePath(env = process.env) {
40
- return path.join(flowerConfigDirectory(env), "telemetry.json");
41
- }
42
-
43
- /**
44
- * 校验并规范化遥测状态。
45
- *
46
- * @param {unknown} value 原始状态
47
- * @returns {object} 规范化状态
48
- */
49
- function normalizeTelemetryState(value) {
50
- if (!value || typeof value !== "object" || Array.isArray(value)) {
51
- throw new TypeError("遥测状态必须是对象");
52
- }
53
- if (value.schemaVersion !== TELEMETRY_SCHEMA_VERSION) {
54
- throw new TypeError("遥测状态 schemaVersion 无效");
55
- }
56
- if (!UUID_PATTERN.test(String(value.deviceId || ""))) {
57
- throw new TypeError("遥测设备 ID 无效");
58
- }
59
- if (typeof value.enabled !== "boolean") {
60
- throw new TypeError("遥测 enabled 无效");
61
- }
62
- const developerName = normalizeDeveloperName(value.developerName);
63
- if (value.developerName !== undefined && value.developerName !== null && !developerName) {
64
- throw new TypeError("遥测 developerName 无效");
65
- }
66
- for (const field of ["lastAttemptAt", "lastSuccessAt"]) {
67
- if (value[field] !== null && (
68
- typeof value[field] !== "string" || Number.isNaN(Date.parse(value[field]))
69
- )) {
70
- throw new TypeError(`遥测 ${field} 无效`);
71
- }
72
- }
73
- return {
74
- schemaVersion: TELEMETRY_SCHEMA_VERSION,
75
- deviceId: value.deviceId,
76
- developerName,
77
- enabled: value.enabled,
78
- lastAttemptAt: value.lastAttemptAt,
79
- lastSuccessAt: value.lastSuccessAt,
80
- };
81
- }
82
-
83
- /**
84
- * 读取用户级遥测状态,并区分缺失与损坏。
85
- *
86
- * @param {{env?:NodeJS.ProcessEnv}} [options] 读取选项
87
- * @returns {{status:"missing"|"valid"|"corrupt",state:object|null,path:string,error?:Error}} 状态读取结果
88
- */
89
- export function readTelemetryState(options = {}) {
90
- const filePath = telemetryStatePath(options.env);
91
- try {
92
- const stat = fs.lstatSync(filePath);
93
- if (stat.isSymbolicLink() || !stat.isFile()) {
94
- throw new TypeError("遥测状态必须是普通文件");
95
- }
96
- const state = normalizeTelemetryState(JSON.parse(fs.readFileSync(filePath, "utf8")));
97
- return { status: "valid", state, path: filePath };
98
- } catch (error) {
99
- if (error?.code === "ENOENT") return { status: "missing", state: null, path: filePath };
100
- return { status: "corrupt", state: null, path: filePath, error };
101
- }
102
- }
103
-
104
- /**
105
- * 原子写入用户级遥测状态。
106
- *
107
- * @param {object} state 已规范化状态
108
- * @param {{env?:NodeJS.ProcessEnv,randomBytes?:(size:number)=>Buffer}} [options] 写入选项
109
- * @returns {object} 写入后的状态
110
- */
111
- function writeTelemetryState(state, options = {}) {
112
- const filePath = telemetryStatePath(options.env);
113
- const parent = path.dirname(filePath);
114
- const randomBytes = options.randomBytes || crypto.randomBytes;
115
- const temporary = path.join(
116
- parent,
117
- `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`,
118
- );
119
- const normalized = normalizeTelemetryState(state);
120
- let descriptor = null;
121
-
122
- fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
123
- try {
124
- fs.chmodSync(parent, 0o700);
125
- } catch {
126
- // Windows 等平台可能不支持 POSIX 权限,目录仍由当前用户配置目录边界保护。
127
- }
128
- try {
129
- const existing = fs.lstatSync(filePath);
130
- if (existing.isSymbolicLink() || !existing.isFile()) {
131
- throw new TypeError(`遥测状态必须是普通文件:${filePath}`);
132
- }
133
- } catch (error) {
134
- if (error?.code !== "ENOENT") throw error;
135
- }
136
-
137
- try {
138
- descriptor = fs.openSync(temporary, "wx", 0o600);
139
- fs.writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
140
- fs.fsyncSync(descriptor);
141
- fs.closeSync(descriptor);
142
- descriptor = null;
143
- fs.renameSync(temporary, filePath);
144
- try {
145
- fs.chmodSync(filePath, 0o600);
146
- } catch {
147
- // Windows 等平台可能不支持 POSIX 权限,原子写入模式仍限制了 Unix 新文件权限。
148
- }
149
- return normalized;
150
- } catch (error) {
151
- if (descriptor !== null) {
152
- try {
153
- fs.closeSync(descriptor);
154
- } catch {
155
- // 保留原始写入错误。
156
- }
157
- }
158
- try {
159
- fs.unlinkSync(temporary);
160
- } catch (cleanupError) {
161
- if (cleanupError?.code !== "ENOENT") throw cleanupError;
162
- }
163
- throw error;
164
- }
165
- }
166
-
167
- /**
168
- * 创建新的默认遥测状态。
169
- *
170
- * @param {{randomUUID?:()=>string}} [options] 随机 ID 注入
171
- * @returns {object} 默认启用的遥测状态
172
- */
173
- function createTelemetryState(options = {}) {
174
- const randomUUID = options.randomUUID || crypto.randomUUID;
175
- return {
176
- schemaVersion: TELEMETRY_SCHEMA_VERSION,
177
- deviceId: randomUUID(),
178
- developerName: null,
179
- enabled: true,
180
- lastAttemptAt: null,
181
- lastSuccessAt: null,
182
- };
183
- }
184
-
185
- /**
186
- * 显式启用或停用用户级遥测。
187
- *
188
- * 损坏状态只允许通过显式设置修复,普通后台上报不会覆盖现场证据。
189
- *
190
- * @param {boolean} enabled 是否启用
191
- * @param {{env?:NodeJS.ProcessEnv,randomUUID?:()=>string,randomBytes?:(size:number)=>Buffer}} [options] 状态选项
192
- * @returns {object} 写入后的遥测状态
193
- */
194
- export function setTelemetryEnabled(enabled, options = {}) {
195
- const result = readTelemetryState(options);
196
- const state = result.status === "valid"
197
- ? result.state
198
- : createTelemetryState(options);
199
- return writeTelemetryState({ ...state, enabled }, options);
200
- }
201
-
202
- /**
203
- * 读取项目 `.trellis/.developer` 中由 Trellis 明确记录的开发者名称。
204
- *
205
- * @param {string} target 项目根目录
206
- * @returns {string|null} 开发者名称
207
- */
208
- function readProjectDeveloper(target) {
209
- try {
210
- const content = fs.readFileSync(path.join(target, ".trellis", ".developer"), "utf8");
211
- const line = content.split(/\r?\n/).find((entry) => entry.startsWith("name="));
212
- return normalizeDeveloperName(line?.slice("name=".length));
213
- } catch {
214
- return null;
215
- }
216
- }
217
-
218
- /**
219
- * 按项目自报、Git 配置、用户级缓存的顺序解析开发者名称。
220
- *
221
- * @param {string} target 项目根目录
222
- * @param {{developerName?:string|null,env?:NodeJS.ProcessEnv}} [options] 回退选项
223
- * @returns {string|null} 开发者名称
224
- */
225
- function resolveDeveloperName(target, options = {}) {
226
- return readProjectDeveloper(target) ||
227
- normalizeDeveloperName(readGitDeveloper(target, { env: options.env })) ||
228
- normalizeDeveloperName(options.developerName);
229
- }
230
-
231
- /**
232
- * 读取项目当前 Flower 与 Trellis 版本,不采集任何项目路径或仓库信息。
233
- *
9
+ /** 将兼容安装快照入队;网络由独立 sender 执行。
234
10
  * @param {string} target 项目根目录
235
- * @returns {{flower:string|null,trellis:string|null}} 项目版本
236
- */
237
- function readProjectVersions(target) {
238
- let projectFlower = null;
239
- let projectTrellis = null;
240
- try {
241
- const lock = new ProjectStore(target).readLock();
242
- const skillGarden = lock?.plugins.find(({ id }) => id === SKILL_GARDEN_PLUGIN_ID);
243
- projectFlower = skillGarden?.version || readManifest(target)?.flowerVersion || null;
244
- } catch {
245
- projectFlower = readManifest(target)?.flowerVersion || null;
246
- }
247
- try {
248
- projectTrellis = fs.readFileSync(path.join(target, ".trellis", ".version"), "utf8").trim();
249
- } catch {
250
- projectTrellis = null;
251
- }
252
- return {
253
- flower: VERSION_PATTERN.test(String(projectFlower || "")) ? projectFlower : null,
254
- trellis: VERSION_PATTERN.test(String(projectTrellis || "")) ? projectTrellis : null,
255
- };
256
- }
257
-
258
- /**
259
- * 构造严格白名单的匿名遥测载荷。
260
- *
261
- * @param {string} target 项目根目录
262
- * @param {string} event 遥测事件
263
- * @param {{deviceId:string,developerName?:string|null,env?:NodeJS.ProcessEnv,now?:Date}} options 设备、身份与时间参数
264
- * @returns {object|null} 可上报载荷;全部身份来源缺失时返回 null
265
- */
266
- export function buildTelemetryPayload(target, event, options) {
267
- if (!EVENTS.has(event)) throw new TypeError(`未知遥测事件:${event}`);
268
- if (!UUID_PATTERN.test(String(options?.deviceId || ""))) {
269
- throw new TypeError("遥测设备 ID 无效");
270
- }
271
- const now = options.now instanceof Date ? options.now : new Date();
272
- const resolvedTarget = path.resolve(target);
273
- const developerName = resolveDeveloperName(resolvedTarget, options);
274
- // 三种可信身份来源都缺失时无法诚实构造开发者名称,保留静默降级作为最后边界。
275
- if (!developerName) return null;
276
- const project = readProjectVersions(resolvedTarget);
277
- const bundledTrellis = trellisVersion();
278
- return {
279
- schema_version: TELEMETRY_SCHEMA_VERSION,
280
- device_id: options.deviceId,
281
- event,
282
- flower_version: flowerVersion(),
283
- bundled_trellis_version: VERSION_PATTERN.test(bundledTrellis) ? bundledTrellis : null,
284
- project_flower_version: project.flower,
285
- project_trellis_version: project.trellis,
286
- developer_name: developerName,
287
- platform: process.platform,
288
- arch: process.arch,
289
- client_time: now.toISOString(),
290
- };
291
- }
292
-
293
- /**
294
- * 判断常规遥测是否仍处于上报间隔内。
295
- *
296
- * @param {object} state 遥测状态
297
- * @param {Date} now 当前时间
298
- * @param {number} intervalHours 上报间隔小时数
299
- * @returns {boolean} 是否应跳过
300
- */
301
- function isWithinInterval(state, now, intervalHours) {
302
- if (!state.lastAttemptAt) return false;
303
- const attemptedAt = new Date(state.lastAttemptAt).getTime();
304
- if (!Number.isFinite(attemptedAt)) return false;
305
- return now.getTime() - attemptedAt < intervalHours * 60 * 60 * 1000;
306
- }
307
-
308
- /**
309
- * 尽力而为地上报 Flower 匿名安装遥测。
310
- *
311
- * 网络、状态文件或服务端失败均只返回状态,不输出日志也不影响调用命令。
312
- *
313
- * @param {string} target 项目根目录
314
- * @param {"version_check"|"init_completed"|"update_completed"} event 遥测事件
315
- * @param {{force?:boolean,env?:NodeJS.ProcessEnv,fetch?:typeof fetch,now?:Date,timeoutMs?:number,endpoint?:string,randomUUID?:()=>string,randomBytes?:(size:number)=>Buffer}} [options] 上报选项
316
- * @returns {Promise<{status:string}>} 上报结果
11
+ * @param {string} event v1 事件
12
+ * @param {object} options 本地采集依赖
13
+ * @returns {Promise<{status:string}>} 入队结果
317
14
  */
318
15
  export async function reportTelemetry(target, event, options = {}) {
319
- try {
320
- const env = options.env || process.env;
321
- if (env.FLOWER_NO_TELEMETRY) return { status: "disabled_by_env" };
322
-
323
- const readResult = readTelemetryState({ env });
324
- if (readResult.status === "corrupt") return { status: "corrupt_state" };
325
- let state = readResult.status === "valid"
326
- ? readResult.state
327
- : createTelemetryState(options);
328
- if (!state.enabled) return { status: "disabled" };
329
-
330
- const now = options.now instanceof Date ? options.now : new Date();
331
- const payload = buildTelemetryPayload(target, event, {
332
- deviceId: state.deviceId,
333
- developerName: state.developerName,
334
- env,
335
- now,
336
- });
337
- if (!payload) return { status: "missing_developer" };
338
- const intervalHours = readUpdateCheck(path.resolve(target)).intervalHours;
339
- if (!options.force && isWithinInterval(state, now, intervalHours)) {
340
- return { status: "throttled" };
341
- }
342
-
343
- state = writeTelemetryState({
344
- ...state,
345
- developerName: payload.developer_name,
346
- lastAttemptAt: now.toISOString(),
347
- }, options);
348
- const controller = new AbortController();
349
- const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
350
- try {
351
- const fetchRequest = options.fetch || fetch;
352
- const response = await fetchRequest(options.endpoint || FLOWER_TELEMETRY_ENDPOINT, {
353
- method: "POST",
354
- headers: { "Content-Type": "application/json" },
355
- body: JSON.stringify(payload),
356
- signal: controller.signal,
357
- });
358
- if (!response.ok) return { status: "failed" };
359
- writeTelemetryState({ ...state, lastSuccessAt: now.toISOString() }, options);
360
- return { status: "reported" };
361
- } finally {
362
- clearTimeout(timer);
363
- }
364
- } catch {
365
- return { status: "failed" };
366
- }
16
+ try { return queueLegacyTelemetry(target, event, options); }
17
+ catch { return { status: "failed" }; }
367
18
  }
@@ -7,6 +7,9 @@
7
7
  "codex/session-start-hooks",
8
8
  "codex/dispatch-mode",
9
9
  "codex/remove-multi-agent-v2",
10
- "claude/startup-update-hook"
10
+ "claude/startup-update-hook",
11
+ "claude/session-start-hooks",
12
+ "claude/telemetry-hooks",
13
+ "codex/telemetry-hooks"
11
14
  ]
12
15
  }
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "flower-claude-session-start-hooks",
4
+ "purpose": "platform_config",
5
+ "operations": [{
6
+ "id": "flower-claude-trellis-session-start",
7
+ "operation": "replace",
8
+ "targets": [{
9
+ "kind": "json",
10
+ "path": ".claude/settings.json",
11
+ "missing": "create",
12
+ "requires": [".claude"]
13
+ }],
14
+ "selector": {
15
+ "type": "json-hook-command",
16
+ "event": "SessionStart",
17
+ "commandNeedle": ".claude/hooks/session-start.py"
18
+ },
19
+ "content": {
20
+ "value": {
21
+ "matcher": "startup|clear|compact",
22
+ "timeout": 30,
23
+ "commandResolver": "claude-session-start",
24
+ "sessionParts": ["state", "rules", "stages"]
25
+ }
26
+ }
27
+ }]
28
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "flower-claude-telemetry-hooks",
4
+ "purpose": "platform_config",
5
+ "operations": [
6
+ {
7
+ "id": "flower-claude-telemetry-sessionstart",
8
+ "operation": "replace",
9
+ "targets": [
10
+ {
11
+ "kind": "json",
12
+ "path": ".claude/settings.json",
13
+ "missing": "create",
14
+ "requires": [
15
+ ".claude"
16
+ ]
17
+ }
18
+ ],
19
+ "selector": {
20
+ "type": "json-hook-command",
21
+ "event": "SessionStart",
22
+ "commandNeedle": ".trellis/scripts/flower_telemetry_hook.py"
23
+ },
24
+ "content": {
25
+ "value": {
26
+ "matcher": "startup|resume|clear|compact",
27
+ "timeout": 5,
28
+ "commandResolver": "claude-flower-telemetry"
29
+ }
30
+ }
31
+ },
32
+ {
33
+ "id": "flower-claude-telemetry-userpromptsubmit",
34
+ "operation": "replace",
35
+ "targets": [
36
+ {
37
+ "kind": "json",
38
+ "path": ".claude/settings.json",
39
+ "missing": "create",
40
+ "requires": [
41
+ ".claude"
42
+ ]
43
+ }
44
+ ],
45
+ "selector": {
46
+ "type": "json-hook-command",
47
+ "event": "UserPromptSubmit",
48
+ "commandNeedle": ".trellis/scripts/flower_telemetry_hook.py"
49
+ },
50
+ "content": {
51
+ "value": {
52
+ "matcher": "*",
53
+ "timeout": 5,
54
+ "commandResolver": "claude-flower-telemetry"
55
+ }
56
+ }
57
+ }
58
+ ]
59
+ }
@@ -19,9 +19,10 @@
19
19
  },
20
20
  "content": {
21
21
  "value": {
22
- "matcher": "startup|resume|clear|compact",
22
+ "matcher": "startup|clear|compact",
23
23
  "timeout": 30,
24
- "commandResolver": "codex-session-start"
24
+ "commandResolver": "codex-session-start",
25
+ "sessionParts": ["state", "rules", "stages"]
25
26
  }
26
27
  }
27
28
  },
@@ -0,0 +1,59 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "flower-codex-telemetry-hooks",
4
+ "purpose": "platform_config",
5
+ "operations": [
6
+ {
7
+ "id": "flower-codex-telemetry-sessionstart",
8
+ "operation": "replace",
9
+ "targets": [
10
+ {
11
+ "kind": "json",
12
+ "path": ".codex/hooks.json",
13
+ "missing": "create",
14
+ "requires": [
15
+ ".codex"
16
+ ]
17
+ }
18
+ ],
19
+ "selector": {
20
+ "type": "json-hook-command",
21
+ "event": "SessionStart",
22
+ "commandNeedle": ".trellis/scripts/flower_telemetry_hook.py"
23
+ },
24
+ "content": {
25
+ "value": {
26
+ "matcher": "startup|resume|clear|compact",
27
+ "timeout": 5,
28
+ "commandResolver": "codex-flower-telemetry"
29
+ }
30
+ }
31
+ },
32
+ {
33
+ "id": "flower-codex-telemetry-userpromptsubmit",
34
+ "operation": "replace",
35
+ "targets": [
36
+ {
37
+ "kind": "json",
38
+ "path": ".codex/hooks.json",
39
+ "missing": "create",
40
+ "requires": [
41
+ ".codex"
42
+ ]
43
+ }
44
+ ],
45
+ "selector": {
46
+ "type": "json-hook-command",
47
+ "event": "UserPromptSubmit",
48
+ "commandNeedle": ".trellis/scripts/flower_telemetry_hook.py"
49
+ },
50
+ "content": {
51
+ "value": {
52
+ "matcher": "*",
53
+ "timeout": 5,
54
+ "commandResolver": "codex-flower-telemetry"
55
+ }
56
+ }
57
+ }
58
+ ]
59
+ }
@@ -21,29 +21,10 @@ import {
21
21
  normalizeContentSelection,
22
22
  selectContentSkillEntries,
23
23
  } from "./content-selection.js";
24
+ import { classifyLockReachability } from "./lock-reachability.js";
24
25
 
25
26
  const SKILL_GARDEN_MIGRATION_OWNER_ID = "flower/skill-garden";
26
27
 
27
- /**
28
- * 计算 lock roots 可达的全部 Plugin。
29
- *
30
- * @param {import("./contracts.js").PluginLock} lock Plugin lock
31
- * @returns {Set<string>} 可达 Plugin ID
32
- */
33
- function reachableLockIds(lock) {
34
- const byId = new Map(lock.plugins.map((plugin) => [plugin.id, plugin]));
35
- const reachable = new Set();
36
- const visit = (id) => {
37
- if (reachable.has(id)) return;
38
- reachable.add(id);
39
- const plugin = byId.get(id);
40
- if (!plugin) return;
41
- Object.keys(plugin.dependencies).sort(compareUtf8).forEach(visit);
42
- };
43
- [...lock.roots].sort(compareUtf8).forEach(visit);
44
- return reachable;
45
- }
46
-
47
28
  /**
48
29
  * 汇总既有 Plugin state 的实际投影平台,供无显式选择的生命周期继续复用。
49
30
  *
@@ -489,10 +470,10 @@ export class PluginApplicationService {
489
470
  }
490
471
  }
491
472
  if (lock) {
492
- const reachable = reachableLockIds(lock);
473
+ const { reachableIds } = classifyLockReachability(pluginsFile.plugins, lock);
493
474
  for (const id of [...lockById.keys()].sort(compareUtf8)) {
494
475
  if (options.id && id !== options.id) continue;
495
- if (!reachable.has(id)) {
476
+ if (!reachableIds.has(id)) {
496
477
  diagnostics.push({
497
478
  code: "verify.lock-orphan",
498
479
  path: id,
@@ -788,6 +769,7 @@ export class PluginApplicationService {
788
769
  if (desiredDirectoryPaths.has(entry.path)) continue;
789
770
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
790
771
  const currentHash = hashDirectoryIfExists(target);
772
+ if (currentHash === null) continue;
791
773
  if (currentHash !== entry.hash) {
792
774
  throw new PluginRuntimeError(`受管 Plugin 目录已被用户修改,拒绝删除:${entry.path}`, {
793
775
  code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
@@ -803,6 +785,7 @@ export class PluginApplicationService {
803
785
  if (desiredPaths.has(entry.path)) continue;
804
786
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
805
787
  const currentHash = hashFileIfExists(target);
788
+ if (currentHash === null) continue;
806
789
  if (currentHash !== entry.hash) {
807
790
  throw new PluginRuntimeError(`受管 Plugin 文件已被用户修改,拒绝删除:${entry.path}`, {
808
791
  code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
@@ -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
+ }
@@ -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
  {