@geoly-ai/skills-hub 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,832 @@
1
+ // 客户端 adapter —— 规范见 04-install.md §2.3 / §3.3、10-open-questions.md Q12
2
+ //
3
+ // 🔴 adapter 是接口不是路径表。这里给出的是**数据 + 派生函数**:数据必须可枚举
4
+ // (`list` 与预检要遍历它),派生函数保证「target 路径」只有一处定义 ——
5
+ // `.gitignore` 模式、post-install 提示、预检的 base 全都从同一份数据派生,
6
+ // 免得像 M0 v8 那样在两个地方各写一遍、其中一处写错(v8 把项目级忽略写成了根上的
7
+ // `/.geoly/`,实际应该是 `/.claude/skills/.geoly/`)。
8
+ import { homedir } from 'node:os';
9
+ import { join, isAbsolute, dirname } from 'node:path';
10
+ import { statSync } from 'node:fs';
11
+
12
+ /** 真实门记录的身份登记。外面拿不到这个引用,也就伪造不出成员资格。 */
13
+ const REAL_GATES = new WeakSet();
14
+
15
+ /**
16
+ * 目录存不存在。
17
+ * 🔴 只有 ENOENT/ENOTDIR 算「不存在」。**EACCES 不能当成不存在** ——
18
+ * 那会让一个存在但读不了的目录被计划成 `willCreate: true` 进 selected,
19
+ * 于是 `--create-missing` 会去「创建」一个已经在那儿的目录。
20
+ * 存在但看不清 → 按「存在」算,后面的 target 预检会用 `not-writable` 拦住。
21
+ */
22
+ const DIR_MISSING = 'missing';
23
+ const DIR_PRESENT = 'present';
24
+ const DIR_CONFLICT = 'conflict'; // 路径被占了,但不是目录
25
+
26
+ const probeDir = (p) => {
27
+ try {
28
+ return statSync(p).isDirectory() ? DIR_PRESENT : DIR_CONFLICT;
29
+ } catch (err) {
30
+ // 🔴 只有 ENOENT/ENOTDIR 算「不存在」。EACCES 不能当成不存在 ——
31
+ // 那会让一个存在但读不了的目录被计划成 `willCreate: true`,
32
+ // 于是 `--create-missing` 会去「创建」一个已经在那儿的目录。
33
+ return err?.code === 'ENOENT' || err?.code === 'ENOTDIR' ? DIR_MISSING : DIR_PRESENT;
34
+ }
35
+ };
36
+
37
+ const isDir = (p) => probeDir(p) === DIR_PRESENT;
38
+
39
+ /** 🔴 门记录必须**深**冻结:浅冻结挡不住 `gate('global').status = 'passed'`。 */
40
+ function deepFreeze(o) {
41
+ for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
42
+ return Object.freeze(o);
43
+ }
44
+
45
+ export const SCOPES = Object.freeze(['global', 'project']);
46
+
47
+ /** 状态目录名。target 内的一切 per-target 状态都在这下面(§3.2)。 */
48
+ export const STATE_DIR = '.geoly';
49
+
50
+ // ── Q12 门 ───────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * 🔴 Q12 是 **M1 的阻塞门**:`<target>/.geoly/` 会不会被客户端误当成 skill。
54
+ * 「未通过的 client 不得合入 adapter,直接标为不支持」(00-decisions.md §5 第 1 条)。
55
+ *
56
+ * 但「未通过」有两种,必须分开,否则要么谎称测过、要么把 M1 全卡死:
57
+ *
58
+ * `passed` —— 有实测证据,enabled。
59
+ * `pending` —— **门还没跑过这个组合**。可枚举、可 `list`,但默认不允许安装。
60
+ * 这不是「测了没过」,所以不能写成 unsupported —— 那会让「跑完门」
61
+ * 这件事从待办变成一个看起来已经有结论的既成事实。
62
+ * `unsupported` —— 实测不通过,或结构上就没有读者。**永远不允许**,不因 flag 放行。
63
+ *
64
+ * ⚠️ 每一条都必须带 `evidence`(指向 docs/m1/00-gates.md 的具体读数)。
65
+ * 没有 evidence 的 `passed` 就是伪造证据 —— 宁可留 `pending`。
66
+ *
67
+ * 🔴 门要**绑定具体客户端版本**(Q12 明文),升级客户端要复测。
68
+ * `clientVersion` 为 null 表示「门还没跑,自然也没有版本可绑」。
69
+ */
70
+ export const GATE_PASSED = 'passed';
71
+ export const GATE_PENDING = 'pending';
72
+ export const GATE_UNSUPPORTED = 'unsupported';
73
+
74
+ /**
75
+ * 🔴 `pending` 的**原因**必须是可枚举的常量,不能是自由文本。
76
+ *
77
+ * Q12 的历史教训是「一个不动的读数被当成了『没影响』」;它的孪生兄弟是
78
+ * **「缺证据」被写成「只是还没拍板」** —— 两者都长得像「快好了」,代价却差一个数量级:
79
+ * 缺决策拍个板就能开,缺证据要重新架一次实验。
80
+ *
81
+ * 所以这里把两类分开,并在模块加载期用 `clientVersion` 的有无**机械地**校验:
82
+ * 证据完整的那一类**必须**带版本号,缺证据的那一类**必须**不带 ——
83
+ * 谁也没法靠改一行 evidence 文案把自己挪到另一类里去。
84
+ */
85
+ /** 证据完整,卡的是「要不要把这一格纳入发车范围」这个人来拍的取舍。 */
86
+ export const BLOCKED_ON_SCOPE_DECISION = 'scope-decision-pending';
87
+ /** 🔴 本机跑不起这个客户端,Q12 要求的运行时验收**没有做过**。 */
88
+ export const BLOCKED_ON_NO_RUNTIME = 'runtime-evidence-unavailable';
89
+
90
+ /** 带这些 blocker 的格子「证据完整」 —— 必须有 clientVersion。 */
91
+ const EVIDENCE_COMPLETE_BLOCKERS = new Set([BLOCKED_ON_SCOPE_DECISION]);
92
+ /** 带这些 blocker 的格子「缺证据」 —— 必须没有 clientVersion,否则就是在冒充测过。 */
93
+ const EVIDENCE_MISSING_BLOCKERS = new Set([BLOCKED_ON_NO_RUNTIME]);
94
+ const KNOWN_BLOCKERS = new Set([...EVIDENCE_COMPLETE_BLOCKERS, ...EVIDENCE_MISSING_BLOCKERS]);
95
+
96
+ /**
97
+ * 🔴 `planTargets` 的**测试注入缝**,Symbol key(同 `target.mjs` 的 `TEST_DEPS`)。
98
+ * 只用来在门尚未闭合时测「门过了之后」的分支;`assertPlanOk` 会拒绝被注入过的计划。
99
+ */
100
+ export const TEST_GATES = Symbol('adapters.planTargets.testGates');
101
+
102
+ /**
103
+ * 🔴 同 `target.mjs` 的 `CLEAN_PRECHECKS`:「这份计划是按真实门表算的」这个事实
104
+ * 存在模块私有的 WeakSet 里,不存在计划对象的字段里 ——
105
+ * 一个 `plan.gatesOverridden = false` 就能把公开字段那道边界拆掉。
106
+ */
107
+ const CLEAN_PLANS = new WeakSet();
108
+
109
+ const ADAPTER_DEFS = [
110
+ {
111
+ client: 'claude',
112
+ dirName: '.claude',
113
+ envHome: null,
114
+ gates: {
115
+ // 🔴 两个 scope 都是实测通过(2026-08-26,claude-code 2.1.246)。
116
+ // 保护机制是**扫描不递归**:`<target>/` 下只看一层。深度对照证明了这一点 ——
117
+ // 把一个有效 skill 放到 `<target>/probe3/tx-1/stage/<n>/SKILL.md`(与 `.geoly`
118
+ // 里 staged skill 完全同深、且目录名不带点),读数**纹丝不动**(16 → 16)。
119
+ //
120
+ // ⚠️ **由此得到一条硬约束**:claude **不过滤点目录**(实测把
121
+ // `<target>/.geoly/SKILL.md` 当成了名为 `.geoly` 的 skill 加载,15 → 16)。
122
+ // 所以 §3.2 的布局**永远不得**在 `<target>/.geoly/SKILL.md` 放文件 ——
123
+ // 这一格的通过完全建立在「.geoly 顶层没有 SKILL.md」之上。
124
+ global: {
125
+ status: GATE_PASSED,
126
+ clientVersion: 'claude-code 2.1.246',
127
+ evidence:
128
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 claude/global:读数取 `claude -p ' +
129
+ '--output-format stream-json` 首条 system/init 事件的 skills 数组长度。' +
130
+ '深度 1 正对照 15 → 16(且 canary 名恰好出现 1 次,证明测量敏感);' +
131
+ '放入完整 .geoly fixture(lock.db/-wal/-shm、generation、ledger.json、' +
132
+ 'audit-seq、journal/1.json、tx-1/stage/<n>/SKILL.md、attic/1/<n>.tar)后仍为 16,' +
133
+ 'staged/attic canary 在 catalog 与**真实发往模型的请求体**里命中数均为 0,' +
134
+ '其余 skill 逐名一致,退出码 0、stderr 与基线逐字节相同',
135
+ },
136
+ project: {
137
+ status: GATE_PASSED,
138
+ clientVersion: 'claude-code 2.1.246',
139
+ evidence:
140
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 claude/project:同 global 的协议,' +
141
+ 'target 取 <projectRoot>/.claude/skills。正对照 15 → 16,' +
142
+ '加 .geoly fixture 后仍为 16,canary 在 catalog 与请求体命中数均为 0,退出码 0',
143
+ },
144
+ },
145
+ postInstallHint: '重启 Claude Code,或在会话里跑 /skills 让它重扫技能目录',
146
+ },
147
+ {
148
+ client: 'cursor',
149
+ dirName: '.cursor',
150
+ envHome: null,
151
+ gates: {
152
+ // 🔴 这两格**没有任何运行时证据**,而且静态分析**指向失败**,不是中性的「还没测」。
153
+ //
154
+ // 本机测不了:cursor-agent 2026.02.27-e7d2ef6 装了但未认证(跑任何命令都直接
155
+ // `Authentication required`,登录要交互式浏览器 OAuth),Cursor IDE 没装。
156
+ //
157
+ // ⚠️ 读它的 bundle(只读)看到的机制是**两道保护都没有**:
158
+ // Agent Skills 加载器逐级 readdir **递归**到深度 10,遇到任何 `SKILL.md` 就收,
159
+ // 目录排除集只有 {node_modules,.git,.svn,.hg,__pycache__,.cache,dist,build,.next,.nuxt}
160
+ // —— 既没有 `.geoly`,也没有任何点目录过滤。而 `.geoly/tx-1/stage/<n>/SKILL.md`
161
+ // 在 target 下只有 3 层。**预判:一旦能跑,它很可能会把 staged skill 当成真 skill 收进去。**
162
+ //
163
+ // 🔴 但预判不是实测,所以**不标 unsupported** —— Q12 要的是运行时验收。
164
+ // 同样**不因为「看起来八成会挂」就当它已经有结论**:这一格要的是把客户端跑起来。
165
+ global: {
166
+ status: GATE_PENDING,
167
+ blockedOn: BLOCKED_ON_NO_RUNTIME,
168
+ clientVersion: null,
169
+ evidence:
170
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 cursor/global:**本机无法测量** —— ' +
171
+ 'cursor-agent 2026.02.27-e7d2ef6 已安装但未认证(需交互式浏览器 OAuth),' +
172
+ 'Cursor IDE 未安装,因此没有任何运行时读数。' +
173
+ '⚠️ 静态分析**预判失败**:其 Agent Skills 加载器递归到深度 10 收集 SKILL.md,' +
174
+ '排除集不含 .geoly 也不过滤点目录,而 .geoly/tx-1/stage/<n>/SKILL.md 只有 3 层。' +
175
+ '要闭合这一格需要:登录 cursor-agent(或装 Cursor IDE)后重跑逐格协议',
176
+ },
177
+ project: {
178
+ status: GATE_PENDING,
179
+ blockedOn: BLOCKED_ON_NO_RUNTIME,
180
+ clientVersion: null,
181
+ evidence:
182
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 cursor/project:同 global —— ' +
183
+ '本机跑不起 cursor 客户端,无运行时读数;静态分析同样预判失败(递归扫描、不过滤点目录)',
184
+ },
185
+ },
186
+ postInstallHint: '重启 Cursor(技能目录在启动时扫描)',
187
+ },
188
+ {
189
+ client: 'codex',
190
+ dirName: '.codex',
191
+ // gates 里就是用 $CODEX_HOME 指到临时目录测的,adapter 要认同一个变量,
192
+ // 否则「门测的路径」与「实际安装的路径」不是同一个,门就白测了。
193
+ envHome: 'CODEX_HOME',
194
+ gates: {
195
+ // 🔴 两个 scope 都是实测通过(2026-08-26,codex-cli 0.147.0)。
196
+ // 保护机制与 claude **不是同一个**:codex 的扫描**是递归的**(深度对照证明:
197
+ // 同深度、非点名目录下的 skill 被收了,6 → 7),挡住 `.geoly` 的是**点目录过滤**
198
+ // (实测 `<target>/.geoly/SKILL.md` 不被加载)。
199
+ // ⚠️ 也就是说两端各只靠**一道**保护,且是不同的那一道 —— 任一端改了扫描策略都要复测。
200
+ global: {
201
+ status: GATE_PASSED,
202
+ clientVersion: 'codex-cli 0.147.0',
203
+ evidence:
204
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 codex/global:读数取 `codex debug ' +
205
+ 'prompt-input` 渲染的模型可见 prompt 里 <skills_instructions> 的条目数。' +
206
+ '深度 1 正对照 5 → 6(canary 名恰好 1 次);**同深度正对照** 6 → 7 ' +
207
+ '(probe3/tx-1/stage/<n>/SKILL.md 被收,证明扫描能到达 .geoly 里 staged skill 的深度);' +
208
+ '再放入完整 .geoly fixture 后仍为 7,canary 命中 0,其余 skill 逐名一致,' +
209
+ '退出码 0、stderr 0 字节。' +
210
+ '⚠️ codex 是离线渲染,没有「请求体」这件产物 —— catalog 与模型可见内容是同一份,' +
211
+ 'claude 那边的请求体证据不外推到这里。' +
212
+ '⚠️ 覆盖边界:加载 + 路由输入,未做端到端的 skill 调用验证',
213
+ },
214
+ project: {
215
+ status: GATE_PASSED,
216
+ clientVersion: 'codex-cli 0.147.0',
217
+ evidence:
218
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 codex/project:同 global 的协议,' +
219
+ 'target 取 <projectRoot>/.codex/skills(cwd 下的 .codex/skills 确实是 codex 的 skill root)。' +
220
+ '正对照 5 → 6、同深度正对照 6 → 7、加 .geoly fixture 后仍为 7,canary 命中 0,退出码 0',
221
+ },
222
+ },
223
+ postInstallHint: '新开一个 codex 会话(catalog 在启动时构建)',
224
+ },
225
+ {
226
+ client: 'agents',
227
+ dirName: '.agents',
228
+ envHome: null,
229
+ // 🔴 **只在已存在时加入,永不创建**(用户 2026-08-27 拍板)。
230
+ //
231
+ // 与 claude/codex 不同:`.agents` 不是某个客户端自己的家目录,而是一条
232
+ // **共享约定路径**,读者是 codex。我们去创建它等于替用户发明一个他没选过的位置,
233
+ // 而且 codex 同时读 `.codex/skills` 与 `.agents/skills` —— 凭空建出来只会让
234
+ // 同一批 skill 在 catalog 里出现两次。
235
+ //
236
+ // 因此 `--create-missing` 对这一端**不适用**:目录不存在时,
237
+ // 默认计划里跳过;显式点名则报错(而不是静默忽略一个明确的请求)。
238
+ presentOnly: true,
239
+ gates: {
240
+ // 🔴 **这一端原先标的 `unsupported / no-reader` 是错的,已推翻。**
241
+ //
242
+ // 原判据是「用固定串 `grep -F '.agents/skills'` 核对二进制,命中 0 → 没有读者」。
243
+ // 假阴性:那条路径是**运行时 join 拼出来的**,二进制里根本不存在这个连续子串。
244
+ // 实测(2026-08-26):codex-cli 0.147.0 把 `$HOME/.agents/skills` 与
245
+ // `<cwd>/.agents/skills` 都当作 skill root 加载 —— 正对照 5 → 6,
246
+ // 且它渲染的 prompt 里直接列出了这两个 root。
247
+ //
248
+ // ⚠️ **「二进制里搜不到这个字符串」证明不了「没有读者」。** 固定串 grep 只能证
249
+ // 存在、不能证不存在;要证不存在得把客户端跑起来做正对照 —— 这正是 Q12 的要求。
250
+ //
251
+ // 现在的状态:**证据完整**(测量有效 + 结论 + 读者版本号),卡的是**范围决策** ——
252
+ // `.agents` 不是一个自己的客户端,它是一条**共享约定路径**,读者是 codex;
253
+ // 同时启用 `codex` 与 `agents` 会让同一批 skill 在 catalog 里出现两次。
254
+ // 「要不要把 .agents 纳入发车范围、以及它跟 codex 的关系怎么算」是人来拍的取舍。
255
+ global: {
256
+ // 范围决策已由用户拍板(2026-08-27):**启用,但只在 `.agents` 已存在时加入,
257
+ // 不存在就不建**。见下方 `presentOnly`。
258
+ status: GATE_PASSED,
259
+ // 🔴 这里记的是**读者**的版本,不是某个叫 agents 的客户端的版本 —— evidence 里写死了这件事。
260
+ clientVersion: 'codex-cli 0.147.0',
261
+ evidence:
262
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 agents/global:**读者是 codex-cli 0.147.0**' +
263
+ '(.agents 没有自己的客户端,是共享约定路径)。深度 1 正对照 5 → 6、' +
264
+ '同深度正对照 6 → 7、加完整 .geoly fixture 后仍为 7,canary 命中 0,退出码 0。' +
265
+ "⚠️ 推翻了早先「grep -F '.agents/skills' 命中 0 ⇒ 无读者」的结论:" +
266
+ '该路径是运行时 join 拼出来的,固定串 grep 搜不到只能证明搜不到,证明不了没有读者',
267
+ },
268
+ project: {
269
+ status: GATE_PASSED,
270
+ clientVersion: 'codex-cli 0.147.0',
271
+ evidence:
272
+ 'docs/m1/00-gates.md Gate 1 逐格实测表 agents/project:读者同为 codex-cli 0.147.0,' +
273
+ 'target 取 <projectRoot>/.agents/skills。正对照 5 → 6、同深度正对照 6 → 7、' +
274
+ '加 .geoly fixture 后仍为 7,canary 命中 0,退出码 0。范围决策同 global',
275
+ },
276
+ },
277
+ postInstallHint: '新开一个 codex 会话(.agents/skills 的读者是 codex,catalog 在启动时构建)',
278
+ },
279
+ ];
280
+
281
+ /** 所有 client 名,按定义顺序(报告与 `list` 的输出顺序要稳定)。 */
282
+ export const CLIENTS = Object.freeze(ADAPTER_DEFS.map((d) => d.client));
283
+
284
+ // ── adapter 接口(§2.3) ─────────────────────────────────────────────────────
285
+
286
+ function makeAdapter(def) {
287
+ const configRoot = ({ home = homedir(), env = process.env } = {}) => {
288
+ if (def.envHome && env[def.envHome]) {
289
+ const v = env[def.envHome];
290
+ if (!isAbsolute(v)) throw new Error(`$${def.envHome} 必须是绝对路径:${v}`);
291
+ return v;
292
+ }
293
+ return join(home, def.dirName);
294
+ };
295
+
296
+ /**
297
+ * target 路径。
298
+ * - `global`:`<configRoot>/skills`
299
+ * - `project`:`<projectRoot>/<dirName>/skills`
300
+ * 🔴 项目级**不看 `$CODEX_HOME`** —— 它是「用户的 codex 家目录」,
301
+ * 与「这个仓库里的 codex 目录」是两件事。
302
+ */
303
+ const root = ({ scope, home = homedir(), env = process.env, projectRoot } = {}) => {
304
+ assertScope(scope);
305
+ if (scope === 'global') return join(configRoot({ home, env }), 'skills');
306
+ if (!projectRoot) throw new Error(`scope=project 需要 projectRoot(client=${def.client})`);
307
+ if (!isAbsolute(projectRoot)) throw new Error(`projectRoot 必须是绝对路径:${projectRoot}`);
308
+ return join(projectRoot, def.dirName, 'skills');
309
+ };
310
+
311
+ /**
312
+ * 🔴 「不存在」不是失败(§2.3):默认目标 = 本机已存在的 client 目录,
313
+ * 不存在的如实报告成 `skipped`,只有 `--create-missing` 才创建。
314
+ */
315
+ const exists = (opts = {}) => isDir(configRoot(opts));
316
+
317
+ /**
318
+ * 🔴 这个 **scope** 的客户端目录在不在。
319
+ * global 看 `<configRoot>`,project 看 `<projectRoot>/<dirName>`。
320
+ * ⚠️ 项目级绝不能拿 `$HOME/.claude` 来判 —— 那是另一台机器上的另一件事,
321
+ * 拿它当判据会让「仓库里根本没有 .claude/」被判成「有」,或反过来。
322
+ */
323
+ const scopeRootDir = (opts = {}) => {
324
+ assertScope(opts.scope);
325
+ if (opts.scope === 'global') return configRoot(opts);
326
+ const { projectRoot } = opts;
327
+ if (!projectRoot) throw new Error(`scope=project 需要 projectRoot(client=${def.client})`);
328
+ return join(projectRoot, def.dirName);
329
+ };
330
+
331
+ /** `missing` / `present` / `conflict`(路径被普通文件之类占了)。 */
332
+ const scopeRootState = (opts = {}) => probeDir(scopeRootDir(opts));
333
+
334
+ const scopeRootExists = (opts = {}) => scopeRootState(opts) === DIR_PRESENT;
335
+
336
+ const targetExists = (opts) => isDir(root(opts));
337
+
338
+ const gate = (scope) => {
339
+ assertScope(scope);
340
+ return def.gates[scope]; // 已深冻结,调用方改不了(见 deepFreeze)
341
+ };
342
+
343
+ /**
344
+ * 这个 scope 能不能装。
345
+ * 🔴 **只有 `passed` 返回 true**。没有任何参数能放行 `pending` 或 `unsupported` ——
346
+ * M0 §5 的原话是「未通过的客户端不得合入 adapter」,一个 `--allow-pending`
347
+ * 就把这条门变成了建议。`pending` 是**门元数据**(供 `list`、报告、以及跑完门后启用),
348
+ * 不是安装绕过机制。要装,就先把门跑完并把结果写进 `docs/m1/00-gates.md`。
349
+ */
350
+ const supports = (scope) => gate(scope).status === GATE_PASSED;
351
+
352
+ /** 布局:target 里的状态目录长什么样(§3.2)。预检要遍历它。 */
353
+ const layout = (opts) => {
354
+ const target = root(opts);
355
+ const state = join(target, STATE_DIR);
356
+ return {
357
+ target,
358
+ state,
359
+ // 🔴 这些路径必须逐个 lstat 无跟随(§3.4),所以要能被枚举出来
360
+ lockDb: join(state, 'lock.db'),
361
+ lockWal: join(state, 'lock.db-wal'),
362
+ lockShm: join(state, 'lock.db-shm'),
363
+ generation: join(state, 'generation'),
364
+ ledger: join(state, 'ledger.json'),
365
+ auditSeq: join(state, 'audit-seq'),
366
+ journalDir: join(state, 'journal'),
367
+ atticDir: join(state, 'attic'),
368
+ quarantineDir: join(state, 'quarantine'),
369
+ auditArchiveDir: join(state, 'audit-archive'),
370
+ repairIntent: join(state, 'repair-intent.json'),
371
+ auditArchiveIntent: join(state, 'audit-archive-intent.json'),
372
+ };
373
+ };
374
+
375
+ const postInstallHint = () => def.postInstallHint;
376
+
377
+ /**
378
+ * 预检用的**可信 base**(§3.4 的 symlink 链检查需要它)。
379
+ * 🔴 不能从 `/` 开始查:macOS 的 `/var` 本身是系统 symlink,从根查会把
380
+ * 每个临时目录都判成假阳性。规格要防的是「我们管辖范围之内被重定向」。
381
+ * - global:`$HOME`(或 `$CODEX_HOME` 的父)
382
+ * - project:`projectRoot`
383
+ */
384
+ const trustedBase = ({ scope, home = homedir(), env = process.env, projectRoot } = {}) => {
385
+ assertScope(scope);
386
+ if (scope === 'project') return projectRoot;
387
+ // 🔴 base 取 configRoot 的**父**,不是 configRoot 本身:
388
+ // ① 这样 `<configRoot>` 这一层本身是不是 symlink 也会被检查到
389
+ // (取 configRoot 当 base 等于先 realpath 掉它、把这一层放过去);
390
+ // ② `$CODEX_HOME` 指向一个尚未创建的目录时,base 仍然存在 ——
391
+ // 否则 `assertNoSymlinkInChain` 会在 realpath(base) 上吃 ENOENT,
392
+ // 把「目录还没建」误报成「路径链上有 symlink」,`--create-missing` 直接没法用。
393
+ return def.envHome && env[def.envHome] ? dirname(configRoot({ home, env })) : home;
394
+ };
395
+
396
+ /** 项目级 `.gitignore` 该忽略的**adapter 派生的实际路径**(§3.3)。 */
397
+ const gitignorePattern = () => `/${def.dirName}/skills/${STATE_DIR}/`;
398
+
399
+ return Object.freeze({
400
+ client: def.client,
401
+ dirName: def.dirName,
402
+ envHome: def.envHome,
403
+ presentOnly: def.presentOnly === true,
404
+ configRoot,
405
+ root,
406
+ exists,
407
+ scopeRootExists,
408
+ scopeRootState,
409
+ scopeRootDir,
410
+ targetExists,
411
+ layout,
412
+ supports,
413
+ gate,
414
+ postInstallHint,
415
+ trustedBase,
416
+ gitignorePattern,
417
+ });
418
+ }
419
+
420
+ function assertScope(scope) {
421
+ if (!SCOPES.includes(scope)) throw new Error(`未知 scope:${scope}(只有 ${SCOPES.join(' / ')})`);
422
+ }
423
+
424
+ /**
425
+ * 🔴 门表的两条不变量,在模块加载时就查死,不留到运行期:
426
+ *
427
+ * ① **`passed` 必须带 `clientVersion`**。Q12 明文「门要绑定具体客户端版本,
428
+ * adapter 或客户端升级时复测」。一条没有版本号的 `passed` 是没法复测的 ——
429
+ * 没人知道它当初测的是哪一版,于是「升级后复测」这条规则永远触发不了。
430
+ * ② **status 只能是那三个之一**。拼错一个字母就会同时躲过 `passed` 的放行判断
431
+ * 与两个拒绝分支,落成静默放行。
432
+ */
433
+ export function assertGateInvariants(defs) {
434
+ const valid = new Set([GATE_PASSED, GATE_PENDING, GATE_UNSUPPORTED]);
435
+ for (const d of defs) {
436
+ for (const scope of SCOPES) {
437
+ const g = d.gates[scope];
438
+ if (!g) throw new Error(`adapter ${d.client} 缺 ${scope} 的门记录`);
439
+ if (!valid.has(g.status)) throw new Error(`adapter ${d.client}/${scope} 的门状态非法:${g.status}`);
440
+ // 🔴 类型也要查,不只是真假。`clientVersion: true` 能骗过所有 `!g.clientVersion`
441
+ // 判断,于是一个**根本不是版本号的东西**就能把格子送进「证据完整」那一档。
442
+ // 空串同理:它是假值,会被当成「没有版本」,但写的人多半以为自己填了。
443
+ // 约定:这几个字段要么**缺席**(undefined/null),要么是**非空字符串**。
444
+ for (const f of ['clientVersion', 'reason', 'blockedOn']) {
445
+ const v = g[f];
446
+ if (v === undefined || v === null) continue;
447
+ if (typeof v !== 'string' || v.trim() === '') {
448
+ throw new Error(
449
+ `adapter ${d.client}/${scope} 的 ${f} 必须是非空字符串或缺席,拿到的是 ${JSON.stringify(v)}`,
450
+ );
451
+ }
452
+ }
453
+ if (!g.evidence) throw new Error(`adapter ${d.client}/${scope} 的门记录缺 evidence`);
454
+ if (g.status === GATE_PASSED && !g.clientVersion) {
455
+ throw new Error(
456
+ `adapter ${d.client}/${scope} 标了 passed 却没有 clientVersion —— ` +
457
+ 'Q12 要求门绑定具体客户端版本,否则升级后无从复测',
458
+ );
459
+ }
460
+ // ③ 🔴 `passed` 不得带 blockedOn。两者同时出现只可能是改了一半:
461
+ // 要么门其实没过(那就别写 passed),要么 blocker 已经消解(那就删掉它)。
462
+ // 留着它会让 `list` 一边说「已启用」一边说「被 X 卡住」。
463
+ if (g.status === GATE_PASSED && g.blockedOn) {
464
+ throw new Error(
465
+ `adapter ${d.client}/${scope} 既是 passed 又带 blockedOn=${g.blockedOn} —— ` +
466
+ '过了的门没有 blocker,这是改了一半',
467
+ );
468
+ }
469
+ // ④ 🔴 `unsupported` 必须给 reason:「不支持」得说清是实测不通过还是结构上没读者,
470
+ // 否则下一个人无从判断该不该重测。
471
+ if (g.status === GATE_UNSUPPORTED && !g.reason) {
472
+ throw new Error(`adapter ${d.client}/${scope} 标了 unsupported 却没有 reason`);
473
+ }
474
+ // ⑤ 🔴 `pending` 必须给一个**白名单内**的 blockedOn,并且
475
+ // 「缺决策」与「缺证据」两类各自与 clientVersion 的有无死死绑住。
476
+ //
477
+ // 这条是整段不变量里最要紧的一条:Q12 栽过的跟头是「不敏感的测量被当成了负结果」,
478
+ // 它在门表里的等价物就是**缺证据的格子伪装成只是没拍板**。两者都只差一个词,
479
+ // 代价却差一个数量级 —— 拍板是一次会议,重做实验是重新架一套客户端。
480
+ // 所以不靠 evidence 文案自证,靠 clientVersion 这个**机械**判据:
481
+ // 没跑过客户端就不可能有版本号,有版本号就说明确实跑过。
482
+ if (g.status === GATE_PENDING) {
483
+ if (!KNOWN_BLOCKERS.has(g.blockedOn)) {
484
+ throw new Error(
485
+ `adapter ${d.client}/${scope} 是 pending 却没有已知的 blockedOn(拿到的是 ${g.blockedOn})—— ` +
486
+ `只能是 ${[...KNOWN_BLOCKERS].join(' / ')};自由文本会让「缺证据」和「缺决策」混成一团`,
487
+ );
488
+ }
489
+ if (EVIDENCE_COMPLETE_BLOCKERS.has(g.blockedOn) && !g.clientVersion) {
490
+ throw new Error(
491
+ `adapter ${d.client}/${scope} 的 blockedOn=${g.blockedOn} 表示「证据完整、只差拍板」,` +
492
+ '那就必须带 clientVersion —— 没有版本号说明门根本没跑过,那是缺证据不是缺决策',
493
+ );
494
+ }
495
+ // 🔴 必须**显式写成 null**,不能靠「字段缺席」蒙混。
496
+ // 只查真假的话,把 `clientVersion: null` 那一行删掉就能悄悄绕过去,
497
+ // 而删一行正是 review 时最容易滑过的改动(Codex 复核时就是这么戳穿的)。
498
+ // 写死 `=== null` 等于逼作者在这一格上**明确表态**「这里没有版本号」。
499
+ if (EVIDENCE_MISSING_BLOCKERS.has(g.blockedOn) && g.clientVersion !== null) {
500
+ throw new Error(
501
+ `adapter ${d.client}/${scope} 的 blockedOn=${g.blockedOn} 表示「没有运行时证据」,` +
502
+ `那 clientVersion 必须显式写成 null(拿到的是 ${JSON.stringify(g.clientVersion)})—— ` +
503
+ '带着版本号是在冒充测过,字段缺席则是把这件事藏起来',
504
+ );
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+ assertGateInvariants(ADAPTER_DEFS);
511
+ ADAPTER_DEFS.forEach(deepFreeze);
512
+ // 登记真实门记录的身份(在冻结之后,登记的就是最终那批对象)
513
+ for (const d of ADAPTER_DEFS) for (const s of SCOPES) REAL_GATES.add(d.gates[s]);
514
+
515
+ const ADAPTERS = Object.freeze(
516
+ Object.fromEntries(ADAPTER_DEFS.map((d) => [d.client, makeAdapter(d)])),
517
+ );
518
+
519
+ /** 🔴 adapter 表必须可枚举 —— `list` 与预检要遍历它。 */
520
+ export function listAdapters() {
521
+ return CLIENTS.map((c) => ADAPTERS[c]);
522
+ }
523
+
524
+ export function getAdapter(client) {
525
+ const a = ADAPTERS[client];
526
+ if (!a) throw new Error(`未知 client:${client}(已知:${CLIENTS.join(', ')})`);
527
+ return a;
528
+ }
529
+
530
+ /**
531
+ * 从一批 adapter def 造出「客户端 → adapter」的表。
532
+ *
533
+ * 🔴 导出它,是为了让测试能用**合成门表**造一套 adapter,非空地验证
534
+ * `supports()` / `gateMatrix()` / `enabledCombos()` 这三个函数**确实是从门状态推出来的**,
535
+ * 而不是碰巧返回了对的东西。真门表 2026-08-26 起有四格闭合了,
536
+ * 但 **`unsupported` 这一档一个格子都没有**(agents 的 no-reader 被实测推翻),
537
+ * 那一支仍然只能靠合成门表非空地测。
538
+ *
539
+ * ⚠️ 合成 adapter 的门记录**不会**进 `REAL_GATES`,因此它们授权不了任何安装。
540
+ */
541
+ export function buildAdapters(defs) {
542
+ assertGateInvariants(defs);
543
+ const list = defs.map((d) => makeAdapter(deepFreeze(d)));
544
+ return Object.freeze({
545
+ clients: Object.freeze(list.map((a) => a.client)),
546
+ list: Object.freeze(list),
547
+ });
548
+ }
549
+
550
+ /** 全部 client × scope 组合及其门状态。给 `list`、给报告、给测试断言。 */
551
+ export function gateMatrix(adapters = listAdapters()) {
552
+ const out = [];
553
+ for (const a of adapters) {
554
+ for (const scope of SCOPES) {
555
+ const g = a.gate(scope);
556
+ out.push(Object.freeze({
557
+ client: a.client,
558
+ scope,
559
+ status: g.status,
560
+ reason: g.reason ?? null,
561
+ blockedOn: g.blockedOn ?? null,
562
+ clientVersion: g.clientVersion ?? null,
563
+ evidence: g.evidence,
564
+ enabled: g.status === GATE_PASSED,
565
+ }));
566
+ }
567
+ }
568
+ return Object.freeze(out);
569
+ }
570
+
571
+ /** 允许安装的组合。🔴 只有 Q12 已过的 —— 没有开关能扩大这个集合。 */
572
+ export function enabledCombos(adapters = listAdapters()) {
573
+ return Object.freeze(gateMatrix(adapters).filter((r) => r.status === GATE_PASSED));
574
+ }
575
+
576
+ /**
577
+ * 把 client + scope 解析成一个完整的 target 描述。
578
+ *
579
+ * 🔴 门在这里**强制**,且**没有放行开关**。
580
+ * 报错必须说清是哪一档、依据是什么 —— 否则用户只看到「不支持」,
581
+ * 分不出「测过不行」与「还没测」,也就不知道该去跑门还是该换客户端。
582
+ *
583
+ * ⚠️ 要在门跑完之前拿到路径(做诊断、写门本身的 fixture),
584
+ * 用 `getAdapter(c).root(...)` / `.layout(...)` —— 它们不判门,因为它们不安装。
585
+ */
586
+ export function resolveTarget({
587
+ client,
588
+ scope,
589
+ home = homedir(),
590
+ env = process.env,
591
+ projectRoot,
592
+ } = {}) {
593
+ const adapter = getAdapter(client);
594
+ assertScope(scope);
595
+ const g = adapter.gate(scope);
596
+ assertGateAllows(g, `${client}/${scope}`);
597
+ return describeTarget(adapter, { scope, home, env, projectRoot }, g.status);
598
+ }
599
+
600
+ /**
601
+ * 门状态的**分类**(纯函数,无授权语义)。
602
+ *
603
+ * 🔴 它**不是**授权函数:给它一个 `{status:'passed'}` 字面量,它当然会说 `allow` ——
604
+ * 那只是在回答「这个 status 属于哪一档」,不是在批准安装。
605
+ * 真正的放行还要求那条门记录**来自本模块深冻结的门表**(见 `assertGateAllows`)。
606
+ *
607
+ * 抽出来是为了能非空地测每一支:真门表里 `unsupported` 现在一个格子都没有,
608
+ * 拿它去测 `deny-unsupported` 分支等于测了个空集。
609
+ */
610
+ export function classifyGate(g) {
611
+ const status = g?.status;
612
+ if (status === GATE_PASSED) return { decision: 'allow', status };
613
+ if (status === GATE_UNSUPPORTED) {
614
+ return {
615
+ decision: 'deny-unsupported',
616
+ status,
617
+ detail: `标为不支持(reason=${g.reason}):${g.evidence}。这是实测结论/结构事实,没有开关能放行`,
618
+ };
619
+ }
620
+ // 🔴 默认拒绝:`pending` 与任何**意料之外的 status** 都走这一支。
621
+ // 白名单式分类才不会因为一个拼错的字面量变成静默放行。
622
+ return {
623
+ decision: 'deny-gate-open',
624
+ status,
625
+ detail:
626
+ `Q12 阻塞门未闭合(status=${status}${g?.blockedOn ? `, blockedOn=${g.blockedOn}` : ''}):` +
627
+ `${g?.evidence}。跑完门并把结果写进 docs/m1/00-gates.md(含被测客户端版本)后改为 passed。` +
628
+ '🔴 没有 --allow-pending 这种开关 —— 那会把阻塞门降级成建议',
629
+ };
630
+ }
631
+
632
+ /**
633
+ * 🔴 **唯一**的放行判据,模块私有。两个条件缺一不可:
634
+ * ① 分类是 `allow`;
635
+ * ② 这条门记录**确实来自本模块的门表**(WeakSet 成员)——
636
+ * 否则外面伪造一个 `{status:'passed'}` 就能授权自己。
637
+ */
638
+ function assertGateAllows(g, label) {
639
+ if (!REAL_GATES.has(g)) {
640
+ throw new Error(`${label} 的门记录不是来自 adapter 门表,拒绝(不接受外部构造的门记录)`);
641
+ }
642
+ const c = classifyGate(g);
643
+ if (c.decision === 'allow') return true;
644
+ throw new Error(`${label} ${c.detail}`);
645
+ }
646
+
647
+
648
+ /**
649
+ * target 描述的**唯一**构造点。`resolveTarget` 与 `planTargets` 共用它 ——
650
+ * 两处各拼一份迟早会漂(`base` 的取法尤其容易只改一边)。
651
+ */
652
+ function describeTarget(adapter, opts, gateStatus, extra = {}) {
653
+ const { home, env } = opts;
654
+ return Object.freeze({
655
+ client: adapter.client,
656
+ scope: opts.scope,
657
+ gate: gateStatus,
658
+ configRoot: adapter.configRoot({ home, env }),
659
+ base: adapter.trustedBase(opts),
660
+ ...adapter.layout(opts),
661
+ adapter,
662
+ ...extra,
663
+ });
664
+ }
665
+
666
+ /**
667
+ * 把「这次命令要装到哪些 target」算出来。
668
+ *
669
+ * 🔴 §2.3 的两条语义必须分开,否则「跳过」会掩盖「用户明确点名了一个装不了的端」:
670
+ *
671
+ * - **默认目标**(没传 `--clients`)= 本机**已存在**的全部 client 目录。
672
+ * 目录不存在 → `skipped: missing-dir`(如实报告,**不是失败**);
673
+ * 门没过 → `skipped: gate-<status>`。
674
+ * - **显式 `--clients`** → 任一项装不了都是**硬错误**,不静默降级成 skipped。
675
+ * (「兼容性不是部分失败」。)
676
+ *
677
+ * `--create-missing` 只影响「目录不存在」这一条,**影响不了门**。
678
+ */
679
+ /**
680
+ * 🔴 同一个读者读了多个被选中的 root —— 装完会在 catalog 里看见重复条目。
681
+ *
682
+ * codex 同时读 `.codex/skills` 与 `.agents/skills`。两端都选中时,
683
+ * 同一个 skill 会**出现两次**,而用户完全看不出为什么。
684
+ *
685
+ * 这里只**告警不拦截**:`.agents` 是用户自己已经建出来的目录,
686
+ * 他要往里装是合理请求;但我们不能假装这件事不会发生。
687
+ */
688
+ const READERS = { claude: ['claude'], codex: ['codex'], agents: ['codex'], cursor: ['cursor'] };
689
+
690
+ function overlapWarnings(selected) {
691
+ const byReader = new Map();
692
+ for (const t of selected) {
693
+ for (const r of READERS[t.client] ?? []) {
694
+ if (!byReader.has(r)) byReader.set(r, []);
695
+ byReader.get(r).push(t.client);
696
+ }
697
+ }
698
+ const out = [];
699
+ for (const [reader, clients] of byReader) {
700
+ if (clients.length < 2) continue;
701
+ out.push({
702
+ kind: 'duplicate-catalog',
703
+ reader,
704
+ clients: [...clients].sort(),
705
+ message:
706
+ `${reader} 会同时读 ${clients.sort().join(' 与 ')} 的 target —— ` +
707
+ '同一个 skill 会在它的 catalog 里出现两次。这是这两个位置本身的重叠,不是安装出错。',
708
+ });
709
+ }
710
+ return out.map((w) => Object.freeze(w));
711
+ }
712
+
713
+ export function planTargets(opts = {}) {
714
+ const {
715
+ clients = null,
716
+ scope = 'global',
717
+ home = homedir(),
718
+ env = process.env,
719
+ projectRoot,
720
+ createMissing = false,
721
+ } = opts;
722
+ assertScope(scope);
723
+ // 🔴 测试注入缝,Symbol key(同 target.mjs 的 TEST_DEPS,理由一样):
724
+ // 用来把「门是什么状态」与「目录/创建逻辑怎么走」解耦:注入一套写死的门表,
725
+ // 这些用例就不会因为真门表的闭合情况变化而跟着改行为
726
+ //(2026-08-26 真门表从「全空」变成「四格闭合」时,只覆盖一半的注入表就漂过一次)。
727
+ // 结果里记 `gatesOverridden`,`assertPlanOk` 会拒绝放行被注入过的计划。
728
+ const gateOverride = opts[TEST_GATES] ?? null;
729
+ const gateOf = (adapter, sc) =>
730
+ gateOverride?.[`${adapter.client}/${sc}`] ?? adapter.gate(sc);
731
+ const explicit = clients != null;
732
+ const list = explicit ? clients : CLIENTS;
733
+ const selected = [];
734
+ const skipped = [];
735
+ const errors = [];
736
+
737
+ for (const client of list) {
738
+ const adapter = getAdapter(client); // 未知 client 直接抛,显式与否都一样
739
+ const g = gateOf(adapter, scope);
740
+ const c = classifyGate(g);
741
+ if (c.decision !== 'allow') {
742
+ const why = `${client}/${scope} 的 Q12 门是 ${g.status}${g.reason ? `(${g.reason})` : ''}:${g.evidence}`;
743
+ if (explicit) errors.push(why);
744
+ else skipped.push({ client, scope, reason: `gate-${g.status}`, message: why });
745
+ continue;
746
+ }
747
+ // 🔴 门过了才谈目录存不存在 —— 反过来会让「目录碰巧不存在」把门的结论盖掉
748
+ // 🔴 判的是**这个 scope 的**目录:project 看 `<repo>/.claude`,不是 `$HOME/.claude`
749
+ const scopeOpts = { scope, home, env, projectRoot };
750
+ const dir = adapter.scopeRootDir(scopeOpts);
751
+ const state = adapter.scopeRootState(scopeOpts);
752
+
753
+ // 🔴 「被普通文件(或别的非目录)占了」既不是 missing 也不是 present。
754
+ // 当成 missing 会让 `--create-missing` 去「创建」一个已经被占的路径,
755
+ // 结果必然 ENOTDIR —— 而且是在事务中途炸,不是在预检时。
756
+ // 这是**硬错误**,`--create-missing` 也解决不了:得先由人把那个文件挪走。
757
+ if (state === DIR_CONFLICT) {
758
+ const why = `${client}/${scope} 的客户端目录 ${dir} 被一个非目录占用(--create-missing 解决不了,需人工挪走)`;
759
+ if (explicit) errors.push(why);
760
+ else skipped.push({ client, scope, reason: 'dir-conflict', message: why });
761
+ continue;
762
+ }
763
+
764
+ const dirExists = state === DIR_PRESENT;
765
+ // 🔴 presentOnly 的端(当前只有 agents)永不创建,`--create-missing` 也不适用。
766
+ // 理由见该 adapter 的定义:它是共享约定路径而非客户端自己的家。
767
+ if (!dirExists && adapter.presentOnly) {
768
+ const why = `${client}/${scope} 的 ${dir} 不存在;这一端只在已存在时加入,不会被创建`;
769
+ if (explicit) errors.push(`${why}(这是有意的:.agents 是共享约定路径,读者是 codex)`);
770
+ else skipped.push({ client, scope, reason: 'present-only-absent', message: why });
771
+ continue;
772
+ }
773
+ if (!dirExists && !createMissing) {
774
+ const why = `${client}/${scope} 的客户端目录 ${dir} 不存在`;
775
+ if (explicit) errors.push(`${why}(要创建请传 --create-missing ${client})`);
776
+ else skipped.push({ client, scope, reason: 'missing-dir', message: why });
777
+ continue;
778
+ }
779
+ selected.push(describeTarget(adapter, scopeOpts, g.status, { willCreate: !dirExists }));
780
+ }
781
+ // 🔴 「兼容性不是部分失败」:显式点名里有一项装不了 → **整批不执行**。
782
+ // 仍然返回 `wouldSelect` 供报错时展示,但 `selected` 必须是空的 ——
783
+ // 调用方忘了看 `errors` 时,最坏结果是什么都没装,而不是装了一半。
784
+ const ok = errors.length === 0;
785
+ const clean = gateOverride === null;
786
+ const plan = Object.freeze({
787
+ ok,
788
+ selected: Object.freeze(ok ? selected : []),
789
+ wouldSelect: Object.freeze(selected),
790
+ skipped: Object.freeze(skipped.map((s) => Object.freeze(s))),
791
+ errors: Object.freeze(errors),
792
+ warnings: Object.freeze(overlapWarnings(selected)),
793
+ explicit,
794
+ gatesOverridden: !clean, // 给人看的;放行判据是 CLEAN_PLANS
795
+ });
796
+ if (clean) CLEAN_PLANS.add(plan);
797
+ return plan;
798
+ }
799
+
800
+ /** 计划有硬错误就抛,报出全部。 */
801
+ export function assertPlanOk(plan) {
802
+ if (!CLEAN_PLANS.has(plan)) {
803
+ throw new Error(
804
+ '这份目标计划不是按真实门表算的(注入了 TEST_GATES,或对象被替换/篡改过),不得用来放行安装',
805
+ );
806
+ }
807
+ if (plan.ok) return plan;
808
+ const err = new Error(
809
+ `目标解析失败(${plan.errors.length} 项):\n` +
810
+ plan.errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n'),
811
+ );
812
+ err.errors = plan.errors;
813
+ throw err;
814
+ }
815
+
816
+ // ── 项目级 .gitignore(§3.3) ────────────────────────────────────────────────
817
+
818
+ /**
819
+ * 🔴 忽略的是 **adapter 派生的实际路径**(`/.claude/skills/.geoly/`),
820
+ * **不是**根上的 `/.geoly/` —— M0 §3.3 明确注明 v8 写错过这一点。
821
+ * 根上那条既挡不住真正的状态目录,又会误伤别的东西。
822
+ */
823
+ export function gitignorePatternsFor(clients = CLIENTS) {
824
+ return clients.map((c) => getAdapter(c).gitignorePattern());
825
+ }
826
+
827
+ /** `git clean -xfd` 的后果必须写进 README / 提示里(§3.3、Q12)。 */
828
+ export const GIT_CLEAN_WARNING =
829
+ '⚠️ `git clean -xfd` 会删掉整个 `.geoly/` —— 不只是进行中的事务状态,' +
830
+ '**还包括本地审计历史**(live `audit` 与 `audit-archive/`)。' +
831
+ '审计历史一旦被清,event_id 序列会重新开始,这是规范承认的「放弃本地 audit」边界,' +
832
+ '但它不可恢复。项目级安装务必先把下面几条加进 `.gitignore`。';