release-skill 0.2.4 → 0.2.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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +24 -0
  7. package/INSTALL.md +4 -4
  8. package/INSTALL.zh-CN.md +4 -4
  9. package/README.md +16 -14
  10. package/README.zh-CN.md +16 -14
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +1375 -607
  14. package/adapters/claude/schemas/release-plan.schema.json +91 -0
  15. package/adapters/claude/schemas/release-run.schema.json +85 -0
  16. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  17. package/adapters/codex/bin/release-skill.bundle.mjs +1375 -607
  18. package/adapters/codex/schemas/release-plan.schema.json +91 -0
  19. package/adapters/codex/schemas/release-run.schema.json +85 -0
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1375 -607
  22. package/adapters/kimi/schemas/release-plan.schema.json +91 -0
  23. package/adapters/kimi/schemas/release-run.schema.json +85 -0
  24. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  25. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1375 -607
  26. package/adapters/workbuddy/schemas/release-plan.schema.json +91 -0
  27. package/adapters/workbuddy/schemas/release-run.schema.json +85 -0
  28. package/bin/release-skill.bundle.mjs +1375 -607
  29. package/package.json +1 -1
  30. package/schemas/release-plan.schema.json +91 -0
  31. package/schemas/release-run.schema.json +85 -0
  32. package/scripts/sync-public-files.mjs +20 -9
  33. package/src/adapters/plugin-marketplace.mjs +82 -3
  34. package/src/commands/prepare.mjs +74 -0
  35. package/src/commands/publish.mjs +62 -0
  36. package/src/commands/verify.mjs +151 -1
  37. package/src/core/plan.mjs +29 -0
  38. package/src/core/skill-resource-closure.mjs +425 -0
  39. package/src/platforms/codebuddy.mjs +16 -3
  40. package/src/platforms/kimi.mjs +36 -9
@@ -51,6 +51,11 @@ import {
51
51
  resolveNpmRegistryAuthToken,
52
52
  } from '../adapters/npm.mjs';
53
53
  import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
54
+ import {
55
+ checkSkillResourceClosure,
56
+ createSkillResourceClosureReceipt,
57
+ evaluateConsumerSkillResourceClosureReceipts,
58
+ } from '../core/skill-resource-closure.mjs';
54
59
  import { isRemoteWriteAction, isMarketplaceAction } from '../core/checkpoints.mjs';
55
60
  import {
56
61
  shouldSkipVerification,
@@ -216,11 +221,13 @@ export async function runSmokeTest(plan, root, options = {}) {
216
221
  skipped: true,
217
222
  details: { message: 'No npm distributions in plan; smoke test skipped' },
218
223
  gateResults: [],
224
+ skillResourceClosureReceipts: [],
219
225
  };
220
226
  }
221
227
 
222
228
  const results = [];
223
229
  const gateResults = [];
230
+ const skillResourceClosureReceipts = [];
224
231
 
225
232
  for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
226
233
  const packageAtVersion = `${pkgName}@${targetVersion}`;
@@ -285,6 +292,47 @@ export async function runSmokeTest(plan, root, options = {}) {
285
292
  }
286
293
 
287
294
  const pkgRoot = join(installDir, 'node_modules', pkgName);
295
+ if (plan.skillResourceClosure) {
296
+ const expectedUnitReceipt = plan.skillResourceClosure.unitReceipts
297
+ .find((item) => item.unitId === unitId);
298
+ if (!expectedUnitReceipt) {
299
+ return {
300
+ passed: false,
301
+ details: { error: `Skill resource closure receipt missing for npm unit "${unitId}"` },
302
+ };
303
+ }
304
+ const closureResult = await checkSkillResourceClosure({
305
+ snapshotDir: pkgRoot,
306
+ host: 'npm',
307
+ });
308
+ if (closureResult.findings.length > 0) {
309
+ return {
310
+ passed: false,
311
+ details: {
312
+ error: `Skill resource closure failed for ${packageAtVersion}`,
313
+ findings: closureResult.findings,
314
+ },
315
+ };
316
+ }
317
+ if (expectedUnitReceipt.skillCount > 0 && closureResult.skillCount === 0) {
318
+ return {
319
+ passed: false,
320
+ details: {
321
+ error: `Installed npm package ${packageAtVersion} contains no skills`,
322
+ },
323
+ };
324
+ }
325
+ skillResourceClosureReceipts.push(createSkillResourceClosureReceipt(
326
+ closureResult,
327
+ {
328
+ unitId,
329
+ distribution: 'npm',
330
+ host: 'npm',
331
+ checkedAt: new Date().toISOString(),
332
+ exitCode: 0,
333
+ },
334
+ ));
335
+ }
288
336
  gateResults.push(...await runConsumerVerificationGates({
289
337
  plan,
290
338
  unitId,
@@ -471,6 +519,7 @@ export async function runSmokeTest(plan, root, options = {}) {
471
519
  count: results.length,
472
520
  },
473
521
  gateResults,
522
+ skillResourceClosureReceipts,
474
523
  };
475
524
  } finally {
476
525
  await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
@@ -1002,7 +1051,10 @@ export async function verifyRelease(options) {
1002
1051
  algorithmVersion: INSTALLATION_CONTRACT_ALGORITHM_VERSION,
1003
1052
  });
1004
1053
 
1005
- if (skipDecision === 'NOT_REQUIRED_UNCHANGED') {
1054
+ // A current-run resource-closure receipt requires a fresh isolated
1055
+ // install. Installation-contract reuse alone cannot prove the
1056
+ // installed Skill resources are reachable.
1057
+ if (skipDecision === 'NOT_REQUIRED_UNCHANGED' && !plan.skillResourceClosure) {
1006
1058
  adapterChecks.push({
1007
1059
  actionId: action.id,
1008
1060
  actionType: action.type,
@@ -1241,6 +1293,103 @@ export async function verifyRelease(options) {
1241
1293
  );
1242
1294
  }
1243
1295
 
1296
+ // =======================================================================
1297
+ // Step 4b: Skill resource closure check on consumer install surfaces
1298
+ // Re-run the closure check on each declared consumer install surface.
1299
+ // If the plan declares skillResourceClosure, every declared host surface
1300
+ // must be checked and pass; undeclared or failed surfaces block VERIFIED.
1301
+ // =======================================================================
1302
+ const skillResourceClosureReceipts = [
1303
+ ...(smokeTest.skillResourceClosureReceipts ?? []),
1304
+ ];
1305
+ if (plan.skillResourceClosure) {
1306
+ await evidence.append({ phase: 'verify', step: 'skill-resource-closure', status: 'started' });
1307
+
1308
+ // Collect install paths from marketplace adapter checks
1309
+ const installSurfaces = [];
1310
+ const actionDistribution = {
1311
+ 'claude-marketplace-install': 'claude-plugin',
1312
+ 'codex-marketplace-install': 'codex-plugin',
1313
+ 'kimi-marketplace-install': 'kimi-plugin',
1314
+ 'codebuddy-marketplace-install': 'codebuddy-plugin',
1315
+ };
1316
+ for (const check of adapterChecks) {
1317
+ const distribution = actionDistribution[check.actionType];
1318
+ if (distribution && check.observation?.installPath) {
1319
+ installSurfaces.push({
1320
+ host: distribution.replace('-plugin', ''),
1321
+ distribution,
1322
+ installPath: check.observation.installPath,
1323
+ actionId: check.actionId,
1324
+ unitId: actions.find((a) => a.id === check.actionId)?.unitId,
1325
+ });
1326
+ }
1327
+ }
1328
+
1329
+ for (const surface of installSurfaces) {
1330
+ const closureResult = await checkSkillResourceClosure({
1331
+ snapshotDir: surface.installPath,
1332
+ host: surface.host,
1333
+ });
1334
+
1335
+ if (closureResult.findings.length > 0) {
1336
+ await evidence.append({
1337
+ phase: 'verify',
1338
+ step: 'skill-resource-closure',
1339
+ status: 'failed',
1340
+ host: surface.host,
1341
+ findingCount: closureResult.findings.length,
1342
+ findings: closureResult.findings,
1343
+ });
1344
+ throw new ReleaseError(
1345
+ POST_PUBLISH_VERIFY_FAILED,
1346
+ `skill resource closure check failed for ${surface.host} consumer install: ${closureResult.findings.length} finding(s)`,
1347
+ { host: surface.host, findings: closureResult.findings },
1348
+ );
1349
+ }
1350
+ const expectedUnitReceipt = plan.skillResourceClosure.unitReceipts
1351
+ .find((item) => item.unitId === surface.unitId);
1352
+ if (!expectedUnitReceipt || closureResult.skillCount === 0) {
1353
+ throw new ReleaseError(
1354
+ POST_PUBLISH_VERIFY_FAILED,
1355
+ `skill resource closure installed surface is empty or unbound for ${surface.distribution}`,
1356
+ { unitId: surface.unitId, distribution: surface.distribution },
1357
+ );
1358
+ }
1359
+ skillResourceClosureReceipts.push(createSkillResourceClosureReceipt(
1360
+ closureResult,
1361
+ {
1362
+ host: surface.host,
1363
+ distribution: surface.distribution,
1364
+ actionId: surface.actionId,
1365
+ unitId: surface.unitId,
1366
+ checkedAt: clockFn(),
1367
+ exitCode: 0,
1368
+ },
1369
+ ));
1370
+ }
1371
+
1372
+ const receiptCoverage = evaluateConsumerSkillResourceClosureReceipts(
1373
+ plan,
1374
+ skillResourceClosureReceipts,
1375
+ );
1376
+ if (!receiptCoverage.passed) {
1377
+ throw new ReleaseError(
1378
+ POST_PUBLISH_VERIFY_FAILED,
1379
+ 'skill resource closure receipt set does not match declared consumer distributions',
1380
+ receiptCoverage,
1381
+ );
1382
+ }
1383
+
1384
+ await evidence.append({
1385
+ phase: 'verify',
1386
+ step: 'skill-resource-closure',
1387
+ status: 'completed',
1388
+ surfaceCount: installSurfaces.length,
1389
+ receipts: skillResourceClosureReceipts,
1390
+ });
1391
+ }
1392
+
1244
1393
  // =======================================================================
1245
1394
  // All verifications passed — write verify run
1246
1395
  // =======================================================================
@@ -1285,6 +1434,7 @@ export async function verifyRelease(options) {
1285
1434
  }),
1286
1435
  gateResults: consumerGateResults,
1287
1436
  consumerVerificationReceipts,
1437
+ skillResourceClosureReceipts,
1288
1438
  startedAt: clockFn(),
1289
1439
  finishedAt: clockFn(),
1290
1440
  };
package/src/core/plan.mjs CHANGED
@@ -165,6 +165,35 @@ export function validatePlan(plan) {
165
165
  );
166
166
  }
167
167
  }
168
+
169
+ if (plan.skillResourceClosure) {
170
+ const receipts = plan.skillResourceClosure.unitReceipts ?? [];
171
+ const receiptUnitIds = receipts.map((item) => item.unitId);
172
+ if (
173
+ new Set(receiptUnitIds).size !== receiptUnitIds.length
174
+ || JSON.stringify([...receiptUnitIds].sort()) !== JSON.stringify([...unitsById.keys()].sort())
175
+ ) {
176
+ throw new ReleaseError(
177
+ GATE_FAILED,
178
+ 'skill resource closure receipts must cover every release unit exactly once',
179
+ );
180
+ }
181
+ const totals = {
182
+ totalSkillCount: receipts.reduce((sum, item) => sum + item.skillCount, 0),
183
+ totalReferenceCount: receipts.reduce((sum, item) => sum + item.referenceCount, 0),
184
+ totalSourceOnlyCount: receipts.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
185
+ totalFindingCount: receipts.reduce((sum, item) => sum + item.findingCount, 0),
186
+ };
187
+ for (const [field, expected] of Object.entries(totals)) {
188
+ if (plan.skillResourceClosure[field] !== expected) {
189
+ throw new ReleaseError(
190
+ GATE_FAILED,
191
+ `skill resource closure ${field} does not match unit receipts`,
192
+ { expected, observed: plan.skillResourceClosure[field] },
193
+ );
194
+ }
195
+ }
196
+ }
168
197
  }
169
198
 
170
199
  /**
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Built-in, read-only Skill resource closure verification.
3
+ *
4
+ * Only path tokens in Markdown code spans, fenced code blocks, and local link
5
+ * targets are interpreted. Bare Skill resources resolve from the directory
6
+ * containing SKILL.md. Explicit plugin-root tokens resolve from the parent of
7
+ * the nearest `skills/` directory.
8
+ */
9
+
10
+ import { lstat, readFile, readdir } from 'node:fs/promises';
11
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
+
13
+ import { canonicalJson, sha256Hex } from './digest.mjs';
14
+ import { GATE_FAILED, ReleaseError } from './errors.mjs';
15
+ import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
16
+
17
+ export const CHECKER_VERSION = 'skill-resource-closure-v1';
18
+
19
+ const BARE_PREFIXES = ['references/', 'assets/', 'schemas/', 'examples/', 'scripts/'];
20
+ const EXPLICIT_PREFIXES = [
21
+ '<plugin-root>/',
22
+ '<plugin_dir>/',
23
+ '$PLUGIN_ROOT/',
24
+ '${PLUGIN_ROOT}/',
25
+ '${CLAUDE_PLUGIN_ROOT}/',
26
+ 'PLUGIN_ROOT/',
27
+ ];
28
+ const SOURCE_TREE_PATTERN = /(?:^|\/)packages\/[A-Za-z0-9._-]+(?:\/|$)/u;
29
+ const MACHINE_ABSOLUTE_PATTERN = /^(?:\/(?:Users|home|root|tmp|var|etc|opt)(?:\/|$)|[A-Za-z]:[\\/])/u;
30
+ const GLOB_PATTERN = /[*?\[]/u;
31
+ const SOURCE_ONLY_MARKER = /(?:\bsource-only\b|仅源码包)/iu;
32
+ const TRANSPORT_EXCLUSIONS = Object.freeze([
33
+ '.git',
34
+ 'node_modules',
35
+ '.in_use',
36
+ '.codex-plugin/migrated-command-skills',
37
+ ]);
38
+
39
+ export const CLASSIFICATION = Object.freeze({
40
+ SKILL_LOCAL: 'skill_local',
41
+ PLUGIN_ROOT: 'plugin_root',
42
+ SOURCE_BACKJUMP: 'source_backjump',
43
+ MACHINE_ABSOLUTE: 'machine_absolute',
44
+ OUT_OF_BOUNDS: 'out_of_bounds',
45
+ });
46
+
47
+ export const FINDING_CODE = Object.freeze({
48
+ SNAPSHOT_UNSAFE: 'SNAPSHOT_UNSAFE',
49
+ RESOURCE_MISSING: 'RESOURCE_MISSING',
50
+ RESOURCE_NOT_REGULAR_FILE: 'RESOURCE_NOT_REGULAR_FILE',
51
+ SOURCE_BACKJUMP: 'SOURCE_BACKJUMP',
52
+ MACHINE_ABSOLUTE_PATH: 'MACHINE_ABSOLUTE_PATH',
53
+ OUT_OF_BOUNDS: 'OUT_OF_BOUNDS',
54
+ SYMLINK_NOT_ALLOWED: 'SYMLINK_NOT_ALLOWED',
55
+ });
56
+
57
+ function toPosix(value) {
58
+ return value.replaceAll('\\', '/');
59
+ }
60
+
61
+ function relativeStable(root, target) {
62
+ const value = toPosix(relative(root, target));
63
+ return value === '' ? '.' : value;
64
+ }
65
+
66
+ function isInside(parent, candidate) {
67
+ const rel = relative(parent, candidate);
68
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
69
+ }
70
+
71
+ export async function discoverSkills(root, base = root) {
72
+ const results = [];
73
+ let entries;
74
+ try {
75
+ entries = await readdir(root, { withFileTypes: true });
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT') return results;
78
+ throw error;
79
+ }
80
+ for (const entry of entries) {
81
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
82
+ const absolute = join(root, entry.name);
83
+ const stableRelative = toPosix(relative(base, absolute));
84
+ if (stableRelative === '.codex-plugin/migrated-command-skills') continue;
85
+ if (entry.isDirectory()) {
86
+ results.push(...await discoverSkills(absolute, base));
87
+ } else if (entry.isFile() && entry.name === 'SKILL.md') {
88
+ results.push(stableRelative);
89
+ }
90
+ }
91
+ return results.sort();
92
+ }
93
+
94
+ export function resolvePluginRoot(skillRelativePath, scanRoot) {
95
+ const parts = toPosix(skillRelativePath).split('/');
96
+ const skillsIndex = parts.lastIndexOf('skills');
97
+ if (skillsIndex < 0) return resolve(scanRoot);
98
+ return resolve(scanRoot, ...parts.slice(0, skillsIndex));
99
+ }
100
+
101
+ export function resolveSkillRoot(skillRelativePath, scanRoot) {
102
+ return resolve(scanRoot, dirname(skillRelativePath));
103
+ }
104
+
105
+ function trimCandidate(value) {
106
+ return value
107
+ .trim()
108
+ .replace(/^["']/u, '')
109
+ .replace(/["']$/u, '')
110
+ .replace(/[.,;:]+$/u, '');
111
+ }
112
+
113
+ function isPathToken(value) {
114
+ const token = trimCandidate(value);
115
+ if (!token || GLOB_PATTERN.test(token)) return false;
116
+ return BARE_PREFIXES.some((prefix) => token.startsWith(prefix))
117
+ || EXPLICIT_PREFIXES.some((prefix) => token.startsWith(prefix))
118
+ || SOURCE_TREE_PATTERN.test(token)
119
+ || MACHINE_ABSOLUTE_PATTERN.test(token);
120
+ }
121
+
122
+ function scanSnippet(snippet, line, sourceOnly, results, seen) {
123
+ const candidates = snippet.match(/(?:"[^"\n]+"|'[^'\n]+'|[^\s"'`()\[\],;]+)/gu) ?? [];
124
+ for (const candidate of candidates) {
125
+ const token = trimCandidate(candidate);
126
+ if (!isPathToken(token)) continue;
127
+ const key = `${line}:${token}`;
128
+ if (seen.has(key)) continue;
129
+ seen.add(key);
130
+ results.push({ token, line, sourceOnly });
131
+ }
132
+ }
133
+
134
+ export function extractPathTokens(content) {
135
+ const results = [];
136
+ const seen = new Set();
137
+ const lines = content.split(/\r?\n/u);
138
+ let inFence = false;
139
+
140
+ for (let index = 0; index < lines.length; index += 1) {
141
+ const line = lines[index];
142
+ const lineNumber = index + 1;
143
+ if (/^\s*```/u.test(line)) {
144
+ inFence = !inFence;
145
+ continue;
146
+ }
147
+ const sourceOnly = SOURCE_ONLY_MARKER.test(line);
148
+ if (inFence) scanSnippet(line, lineNumber, sourceOnly, results, seen);
149
+
150
+ for (const match of line.matchAll(/`([^`]+)`/gu)) {
151
+ scanSnippet(match[1], lineNumber, sourceOnly, results, seen);
152
+ }
153
+ for (const match of line.matchAll(/\]\(([^)]+)\)/gu)) {
154
+ const target = match[1].trim().split(/[?#]/u)[0];
155
+ if (!/^(?:https?:|#)/iu.test(target)) {
156
+ scanSnippet(target, lineNumber, sourceOnly, results, seen);
157
+ }
158
+ }
159
+ }
160
+ return results;
161
+ }
162
+
163
+ function stripExplicitPrefix(token) {
164
+ for (const prefix of EXPLICIT_PREFIXES) {
165
+ if (token.startsWith(prefix)) return token.slice(prefix.length);
166
+ }
167
+ return token;
168
+ }
169
+
170
+ export function classifyReference(token, skillRoot, pluginRoot, scanRoot) {
171
+ if (MACHINE_ABSOLUTE_PATTERN.test(token)) {
172
+ return {
173
+ classification: CLASSIFICATION.MACHINE_ABSOLUTE,
174
+ resolutionRoot: '(machine)',
175
+ resolvedTarget: token,
176
+ absoluteTarget: token,
177
+ };
178
+ }
179
+ if (SOURCE_TREE_PATTERN.test(token)) {
180
+ return {
181
+ classification: CLASSIFICATION.SOURCE_BACKJUMP,
182
+ resolutionRoot: '(source-tree)',
183
+ resolvedTarget: token,
184
+ absoluteTarget: resolve(scanRoot, token),
185
+ };
186
+ }
187
+
188
+ const explicit = EXPLICIT_PREFIXES.some((prefix) => token.startsWith(prefix));
189
+ const resolutionRootAbsolute = explicit ? pluginRoot : skillRoot;
190
+ const relativeTarget = explicit ? stripExplicitPrefix(token) : token;
191
+ const absoluteTarget = resolve(resolutionRootAbsolute, relativeTarget);
192
+ const inBounds = isInside(resolutionRootAbsolute, absoluteTarget);
193
+ return {
194
+ classification: inBounds
195
+ ? (explicit ? CLASSIFICATION.PLUGIN_ROOT : CLASSIFICATION.SKILL_LOCAL)
196
+ : CLASSIFICATION.OUT_OF_BOUNDS,
197
+ resolutionRoot: relativeStable(scanRoot, resolutionRootAbsolute),
198
+ resolvedTarget: relativeStable(scanRoot, absoluteTarget),
199
+ absoluteTarget,
200
+ };
201
+ }
202
+
203
+ async function inspectRegularFile(root, target) {
204
+ if (!isInside(root, target)) return { code: FINDING_CODE.OUT_OF_BOUNDS };
205
+ const parts = relative(root, target).split(sep).filter(Boolean);
206
+ let current = root;
207
+ const rootStat = await lstat(root);
208
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
209
+ return { code: FINDING_CODE.SYMLINK_NOT_ALLOWED };
210
+ }
211
+ for (const part of parts) {
212
+ current = join(current, part);
213
+ let stat;
214
+ try {
215
+ stat = await lstat(current);
216
+ } catch (error) {
217
+ if (error.code === 'ENOENT') return { code: FINDING_CODE.RESOURCE_MISSING };
218
+ throw error;
219
+ }
220
+ if (stat.isSymbolicLink()) return { code: FINDING_CODE.SYMLINK_NOT_ALLOWED };
221
+ }
222
+ const targetStat = await lstat(target);
223
+ if (!targetStat.isFile() || targetStat.nlink !== 1) {
224
+ return { code: FINDING_CODE.RESOURCE_NOT_REGULAR_FILE };
225
+ }
226
+ return { code: null };
227
+ }
228
+
229
+ function inferSurfaceHost(surfaceId, defaultHost) {
230
+ const match = /^adapters\/([^/]+)$/u.exec(surfaceId);
231
+ return match?.[1] ?? defaultHost;
232
+ }
233
+
234
+ function receiptProjection(result) {
235
+ return {
236
+ checkerVersion: result.checkerVersion,
237
+ inputDigest: result.inputDigest,
238
+ surfaces: result.surfaces,
239
+ skillCount: result.skillCount,
240
+ referenceCount: result.referenceCount,
241
+ sourceOnlyCount: result.sourceOnlyCount,
242
+ findingCount: result.findings.length,
243
+ };
244
+ }
245
+
246
+ export function createSkillResourceClosureReceipt(result, identity = {}) {
247
+ return {
248
+ ...identity,
249
+ checkerVersion: result.checkerVersion,
250
+ inputDigest: result.inputDigest,
251
+ surfaceCount: result.surfaces.length,
252
+ skillCount: result.skillCount,
253
+ referenceCount: result.referenceCount,
254
+ sourceOnlyCount: result.sourceOnlyCount,
255
+ findingCount: result.findings.length,
256
+ receiptDigest: result.receiptDigest,
257
+ };
258
+ }
259
+
260
+ export function assertSkillResourceClosureReceipt(expected, observed, label = 'release unit') {
261
+ if (canonicalJson(expected) !== canonicalJson(observed)) {
262
+ throw new ReleaseError(
263
+ GATE_FAILED,
264
+ `skill resource closure receipt changed for ${label}`,
265
+ { expected, observed },
266
+ );
267
+ }
268
+ }
269
+
270
+ export function evaluateConsumerSkillResourceClosureReceipts(plan, receipts) {
271
+ const supported = new Set([
272
+ 'npm',
273
+ 'claude-plugin',
274
+ 'codex-plugin',
275
+ 'kimi-plugin',
276
+ 'codebuddy-plugin',
277
+ ]);
278
+ const expectedKeys = [];
279
+ for (const unit of plan.units ?? []) {
280
+ for (const distribution of unit.distributions ?? []) {
281
+ if (supported.has(distribution.type)) {
282
+ expectedKeys.push(`${unit.id}:${distribution.type}`);
283
+ }
284
+ }
285
+ }
286
+ const observedKeys = receipts.map((item) => `${item.unitId}:${item.distribution}`);
287
+ return {
288
+ passed: new Set(observedKeys).size === observedKeys.length
289
+ && canonicalJson([...expectedKeys].sort()) === canonicalJson([...observedKeys].sort()),
290
+ expectedKeys: expectedKeys.sort(),
291
+ observedKeys: observedKeys.sort(),
292
+ };
293
+ }
294
+
295
+ export async function checkSkillResourceClosure({
296
+ snapshotDir,
297
+ host = 'root',
298
+ } = {}) {
299
+ if (typeof snapshotDir !== 'string' || snapshotDir.length === 0) {
300
+ throw new TypeError('snapshotDir must be a non-empty string');
301
+ }
302
+ const scanRoot = resolve(snapshotDir);
303
+ const scanStat = await lstat(scanRoot);
304
+ if (!scanStat.isDirectory() || scanStat.isSymbolicLink()) {
305
+ throw new Error('snapshotDir must be a real directory');
306
+ }
307
+
308
+ let inputDigest;
309
+ let snapshotError = null;
310
+ try {
311
+ inputDigest = (await computeFrozenSnapshot(scanRoot, {
312
+ excludeRootEntries: TRANSPORT_EXCLUSIONS,
313
+ })).digest;
314
+ } catch (error) {
315
+ snapshotError = error;
316
+ inputDigest = sha256Hex(canonicalJson({
317
+ invalidTree: error.message,
318
+ }));
319
+ }
320
+ const skillPaths = await discoverSkills(scanRoot);
321
+ const findings = [];
322
+ const surfaceMap = new Map();
323
+ let referenceCount = 0;
324
+ let sourceOnlyCount = 0;
325
+
326
+ for (const skill of skillPaths) {
327
+ const skillRoot = resolveSkillRoot(skill, scanRoot);
328
+ const pluginRoot = resolvePluginRoot(skill, scanRoot);
329
+ const surfaceId = relativeStable(scanRoot, pluginRoot);
330
+ const surfaceHost = inferSurfaceHost(surfaceId, host);
331
+ const surface = surfaceMap.get(surfaceId) ?? {
332
+ id: surfaceId,
333
+ host: surfaceHost,
334
+ skillCount: 0,
335
+ referenceCount: 0,
336
+ sourceOnlyCount: 0,
337
+ };
338
+ surface.skillCount += 1;
339
+ surfaceMap.set(surfaceId, surface);
340
+
341
+ const content = await readFile(resolve(scanRoot, skill), 'utf8');
342
+ for (const pathToken of extractPathTokens(content)) {
343
+ referenceCount += 1;
344
+ surface.referenceCount += 1;
345
+ const classification = classifyReference(
346
+ pathToken.token,
347
+ skillRoot,
348
+ pluginRoot,
349
+ scanRoot,
350
+ );
351
+ const findingBase = {
352
+ host: surfaceHost,
353
+ surface: surfaceId,
354
+ skill,
355
+ line: pathToken.line,
356
+ reference: pathToken.token,
357
+ classification: classification.classification,
358
+ resolutionRoot: classification.resolutionRoot,
359
+ resolvedTarget: classification.resolvedTarget,
360
+ };
361
+
362
+ if (classification.classification === CLASSIFICATION.SOURCE_BACKJUMP) {
363
+ if (pathToken.sourceOnly) {
364
+ sourceOnlyCount += 1;
365
+ surface.sourceOnlyCount += 1;
366
+ } else {
367
+ findings.push({ ...findingBase, code: FINDING_CODE.SOURCE_BACKJUMP });
368
+ }
369
+ continue;
370
+ }
371
+ if (classification.classification === CLASSIFICATION.MACHINE_ABSOLUTE) {
372
+ findings.push({ ...findingBase, code: FINDING_CODE.MACHINE_ABSOLUTE_PATH });
373
+ continue;
374
+ }
375
+ if (classification.classification === CLASSIFICATION.OUT_OF_BOUNDS) {
376
+ findings.push({ ...findingBase, code: FINDING_CODE.OUT_OF_BOUNDS });
377
+ continue;
378
+ }
379
+
380
+ const resolutionRootAbsolute = classification.classification === CLASSIFICATION.PLUGIN_ROOT
381
+ ? pluginRoot
382
+ : skillRoot;
383
+ const inspection = await inspectRegularFile(
384
+ resolutionRootAbsolute,
385
+ classification.absoluteTarget,
386
+ );
387
+ if (inspection.code) {
388
+ if (pathToken.sourceOnly && inspection.code === FINDING_CODE.RESOURCE_MISSING) {
389
+ sourceOnlyCount += 1;
390
+ surface.sourceOnlyCount += 1;
391
+ } else {
392
+ findings.push({ ...findingBase, code: inspection.code });
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ if (snapshotError) {
399
+ findings.push({
400
+ host,
401
+ surface: '.',
402
+ skill: null,
403
+ line: null,
404
+ reference: '(snapshot tree)',
405
+ classification: CLASSIFICATION.OUT_OF_BOUNDS,
406
+ resolutionRoot: '.',
407
+ resolvedTarget: '.',
408
+ code: FINDING_CODE.SNAPSHOT_UNSAFE,
409
+ reason: snapshotError.message,
410
+ });
411
+ }
412
+
413
+ const surfaces = [...surfaceMap.values()].sort((a, b) => a.id.localeCompare(b.id));
414
+ const result = {
415
+ checkerVersion: CHECKER_VERSION,
416
+ inputDigest,
417
+ surfaces,
418
+ skillCount: skillPaths.length,
419
+ referenceCount,
420
+ sourceOnlyCount,
421
+ findings,
422
+ };
423
+ result.receiptDigest = sha256Hex(canonicalJson(receiptProjection(result)));
424
+ return result;
425
+ }