release-skill 0.2.6 → 0.2.7

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/.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 +21 -0
  7. package/INSTALL.md +12 -12
  8. package/INSTALL.zh-CN.md +10 -10
  9. package/README.md +26 -11
  10. package/README.zh-CN.md +22 -11
  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 +1579 -1186
  14. package/adapters/claude/skills/release-prepare/SKILL.md +5 -0
  15. package/adapters/claude/skills/release-setup/SKILL.md +10 -1
  16. package/adapters/claude/skills/release-verify/SKILL.md +4 -2
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +1579 -1186
  19. package/adapters/codex/skills/release-prepare/SKILL.md +5 -0
  20. package/adapters/codex/skills/release-setup/SKILL.md +10 -1
  21. package/adapters/codex/skills/release-verify/SKILL.md +4 -2
  22. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  23. package/adapters/kimi/bin/release-skill.bundle.mjs +1579 -1186
  24. package/adapters/kimi/skills/release-prepare/SKILL.md +5 -0
  25. package/adapters/kimi/skills/release-setup/SKILL.md +10 -1
  26. package/adapters/kimi/skills/release-verify/SKILL.md +4 -2
  27. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  28. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1579 -1186
  29. package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -0
  30. package/adapters/workbuddy/skills/release-setup/SKILL.md +10 -1
  31. package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -2
  32. package/bin/release-skill.bundle.mjs +1579 -1186
  33. package/package.json +1 -1
  34. package/skills/release-prepare/SKILL.md +5 -0
  35. package/skills/release-setup/SKILL.md +10 -1
  36. package/skills/release-verify/SKILL.md +4 -2
  37. package/skills-src/release-prepare/SKILL.md +5 -0
  38. package/skills-src/release-setup/SKILL.md +10 -1
  39. package/skills-src/release-verify/SKILL.md +4 -2
  40. package/src/adapters/npm.mjs +54 -2
  41. package/src/adapters/plugin-marketplace.mjs +4 -29
  42. package/src/commands/prepare.mjs +3 -3
  43. package/src/commands/publish.mjs +2 -2
  44. package/src/commands/reconcile.mjs +39 -0
  45. package/src/commands/setup.mjs +168 -0
  46. package/src/commands/verify.mjs +28 -1
  47. package/src/npm/npm-entry-closure.mjs +195 -0
  48. package/src/platforms/codebuddy.mjs +19 -11
  49. package/src/platforms/codex.mjs +18 -10
  50. package/src/platforms/kimi.mjs +26 -39
  51. package/src/snapshot/frozen.mjs +51 -19
@@ -63,6 +63,10 @@ import {
63
63
  shouldSkipVerification,
64
64
  INSTALLATION_CONTRACT_ALGORITHM_VERSION,
65
65
  } from '../core/installation-contract.mjs';
66
+ import {
67
+ buildDirectoryFileIndex,
68
+ checkNpmEntryClosure,
69
+ } from '../npm/npm-entry-closure.mjs';
66
70
 
67
71
  // ---------------------------------------------------------------------------
68
72
  // Constants
@@ -294,6 +298,30 @@ export async function runSmokeTest(plan, root, options = {}) {
294
298
  }
295
299
 
296
300
  const pkgRoot = join(installDir, 'node_modules', pkgName);
301
+
302
+ // Entry closure check: verify all declared entry points (bin, main,
303
+ // module, types, typings, exports) exist as regular files in the
304
+ // installed package. This catches incident-class tarballs where
305
+ // name/version are correct but distribution files are missing.
306
+ {
307
+ const dirIndex = await buildDirectoryFileIndex(pkgRoot);
308
+ const closureResult = checkNpmEntryClosure(installedPkg, dirIndex);
309
+ if (closureResult.errors.length > 0) {
310
+ return {
311
+ passed: false,
312
+ details: {
313
+ gate: 'npm-entry-closure',
314
+ error: `npm entry closure check failed for ${packageAtVersion}: ${closureResult.errors.map((e) => e.message).join('; ')}`,
315
+ packageAtVersion,
316
+ unitId,
317
+ entries: closureResult.entries,
318
+ errors: closureResult.errors,
319
+ diagnostics: closureResult.diagnostics,
320
+ },
321
+ };
322
+ }
323
+ }
324
+
297
325
  if (plan.skillResourceClosure) {
298
326
  const expectedUnitReceipt = plan.skillResourceClosure.unitReceipts
299
327
  .find((item) => item.unitId === unitId);
@@ -1225,7 +1253,6 @@ export async function verifyRelease(options) {
1225
1253
  }
1226
1254
  : {
1227
1255
  HOME: resolve(runDir, 'consumers', `kimi-${action.parameters.plugin}`),
1228
- KIMI_CODE_HOME: resolve(runDir, 'consumers', `kimi-${action.parameters.plugin}`),
1229
1256
  },
1230
1257
  }));
1231
1258
  } else {
@@ -0,0 +1,195 @@
1
+ import { tmpdir } from 'node:os';
2
+
3
+ import {
4
+ buildNpmTarballFileIndex,
5
+ computeFrozenSnapshot,
6
+ } from '../snapshot/frozen.mjs';
7
+
8
+ const SIMPLE_FIELDS = ['main', 'module', 'types', 'typings'];
9
+
10
+ function entryError(field, target, reason, message) {
11
+ return { field, target, reason, message };
12
+ }
13
+
14
+ function validateRelativeTarget(target, field, { requireDotSlash = false } = {}) {
15
+ if (typeof target !== 'string' || target.length === 0) {
16
+ return {
17
+ error: entryError(field, String(target ?? ''), 'invalid_type', `${field} target must be a non-empty string`),
18
+ };
19
+ }
20
+ if (target.includes('\0')) {
21
+ return { error: entryError(field, target, 'nul_in_path', `${field} target must not contain NUL`) };
22
+ }
23
+ if (
24
+ target.startsWith('/') ||
25
+ /^[A-Za-z]:[/\\]/.test(target) ||
26
+ target.startsWith('\\\\')
27
+ ) {
28
+ return { error: entryError(field, target, 'absolute_path', `${field} target must be package-relative`) };
29
+ }
30
+ if (target.includes('\\')) {
31
+ return { error: entryError(field, target, 'backslash_path', `${field} target must use forward slashes`) };
32
+ }
33
+ if (requireDotSlash && !target.startsWith('./')) {
34
+ return {
35
+ error: entryError(field, target, 'missing_dot_slash', `${field} target must start with "./"`),
36
+ };
37
+ }
38
+
39
+ const relativeTarget = target.startsWith('./') ? target.slice(2) : target;
40
+ const segments = relativeTarget.split('/');
41
+ if (segments.includes('..')) {
42
+ return {
43
+ error: entryError(field, target, 'path_escape', `${field} target escapes the package root`),
44
+ };
45
+ }
46
+ if (
47
+ relativeTarget.length === 0 ||
48
+ segments.some((segment) => segment === '' || segment === '.')
49
+ ) {
50
+ return {
51
+ error: entryError(field, target, 'unsafe_segment', `${field} target contains an unsafe path segment`),
52
+ };
53
+ }
54
+ if (requireDotSlash && segments.includes('node_modules')) {
55
+ return {
56
+ error: entryError(field, target, 'unsafe_segment', `${field} target must not contain node_modules`),
57
+ };
58
+ }
59
+ return { target: relativeTarget };
60
+ }
61
+
62
+ function checkTarget({ field, target, fileIndex, requireDotSlash = false }) {
63
+ const validated = validateRelativeTarget(target, field, { requireDotSlash });
64
+ if (validated.error) return { errors: [validated.error], entries: [] };
65
+
66
+ const indexed = fileIndex.get(validated.target);
67
+ const found = indexed?.type === 'file';
68
+ const entries = [{ field, target: validated.target, found }];
69
+ if (found) return { entries, errors: [] };
70
+
71
+ const reason = indexed === undefined ? 'entry_missing' : 'entry_not_regular_file';
72
+ return {
73
+ entries,
74
+ errors: [
75
+ entryError(
76
+ field,
77
+ validated.target,
78
+ reason,
79
+ indexed === undefined
80
+ ? `${field} target "${validated.target}" is missing`
81
+ : `${field} target "${validated.target}" is not a regular file`,
82
+ ),
83
+ ],
84
+ };
85
+ }
86
+
87
+ function collectExports(value, location, result) {
88
+ if (value === null) return;
89
+ if (typeof value === 'string') {
90
+ if (value.includes('*')) {
91
+ result.errors.push(
92
+ entryError(
93
+ 'exports',
94
+ value,
95
+ 'unsupported_entry_shape',
96
+ `${location} uses a wildcard target that the static gate cannot verify`,
97
+ ),
98
+ );
99
+ } else {
100
+ result.targets.push({ field: `exports ${location}`, target: value });
101
+ }
102
+ return;
103
+ }
104
+ if (Array.isArray(value)) {
105
+ result.errors.push(
106
+ entryError('exports', location, 'unsupported_entry_shape', `${location} uses unsupported fallback-array semantics`),
107
+ );
108
+ return;
109
+ }
110
+ if (typeof value !== 'object' || value === undefined) {
111
+ result.errors.push(
112
+ entryError('exports', String(value), 'invalid_exports_type', `${location} has an unsupported exports value`),
113
+ );
114
+ return;
115
+ }
116
+
117
+ for (const [key, child] of Object.entries(value)) {
118
+ const childLocation = `${location}.${key}`;
119
+ if (key.includes('*')) {
120
+ result.errors.push(
121
+ entryError(
122
+ 'exports',
123
+ key,
124
+ 'unsupported_entry_shape',
125
+ `${childLocation} uses a wildcard subpath that the static gate cannot verify`,
126
+ ),
127
+ );
128
+ continue;
129
+ }
130
+ collectExports(child, childLocation, result);
131
+ }
132
+ }
133
+
134
+ export function checkNpmEntryClosure(manifest, fileIndex) {
135
+ const result = { entries: [], errors: [], diagnostics: [] };
136
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
137
+ result.errors.push(entryError('manifest', '', 'invalid_manifest', 'manifest must be an object'));
138
+ return result;
139
+ }
140
+ if (!(fileIndex instanceof Map)) {
141
+ result.errors.push(entryError('fileIndex', '', 'invalid_file_index', 'fileIndex must be a Map'));
142
+ return result;
143
+ }
144
+
145
+ if (manifest.bin !== undefined && manifest.bin !== null) {
146
+ if (typeof manifest.bin === 'string') {
147
+ const checked = checkTarget({ field: 'bin', target: manifest.bin, fileIndex });
148
+ result.entries.push(...checked.entries);
149
+ result.errors.push(...checked.errors);
150
+ } else if (typeof manifest.bin === 'object' && !Array.isArray(manifest.bin)) {
151
+ for (const [name, target] of Object.entries(manifest.bin)) {
152
+ const checked = checkTarget({ field: 'bin', target, fileIndex });
153
+ result.entries.push(...checked.entries);
154
+ result.errors.push(...checked.errors);
155
+ }
156
+ } else {
157
+ result.errors.push(entryError('bin', String(manifest.bin), 'invalid_type', 'bin must be a string or object'));
158
+ }
159
+ }
160
+
161
+ for (const field of SIMPLE_FIELDS) {
162
+ if (manifest[field] === undefined || manifest[field] === null) continue;
163
+ const checked = checkTarget({ field, target: manifest[field], fileIndex });
164
+ result.entries.push(...checked.entries);
165
+ result.errors.push(...checked.errors);
166
+ }
167
+
168
+ if (manifest.exports !== undefined) {
169
+ const exportsResult = { targets: [], errors: [], diagnostics: [] };
170
+ collectExports(manifest.exports, 'exports', exportsResult);
171
+ result.errors.push(...exportsResult.errors);
172
+ result.diagnostics.push(...exportsResult.diagnostics);
173
+ for (const target of exportsResult.targets) {
174
+ const checked = checkTarget({
175
+ field: 'exports',
176
+ target: target.target,
177
+ fileIndex,
178
+ requireDotSlash: true,
179
+ });
180
+ result.entries.push(...checked.entries);
181
+ result.errors.push(...checked.errors);
182
+ }
183
+ }
184
+
185
+ return result;
186
+ }
187
+
188
+ export async function buildTarballFileIndex(tarballBytes, tarballDir = tmpdir()) {
189
+ return buildNpmTarballFileIndex({ tarballBytes, tarballDir });
190
+ }
191
+
192
+ export async function buildDirectoryFileIndex(packageDir) {
193
+ const snapshot = await computeFrozenSnapshot(packageDir, { excludeRootEntries: ['.git'] });
194
+ return new Map(snapshot.entries.map((entry) => [entry.path, { type: entry.type }]));
195
+ }
@@ -141,13 +141,15 @@ export async function resolveCodeBuddyBoundPlanDigest(context) {
141
141
  /**
142
142
  * Authoritative, cross-run attestation directory for a codebuddy install.
143
143
  *
144
- * Lives at a stable root-fixed location keyed by the verified frozen plan
145
- * digest and plugin id:
146
- * <root>/.release-skill/codebuddy-attestations/<planDigest>/<plugin>/
144
+ * Lives at a stable root-fixed location keyed by the plugin id:
145
+ * <root>/.release-skill/codebuddy-attestations/<plugin>/
147
146
  *
148
147
  * This survives the publish -> manual install -> reconcile -> verify chain,
149
148
  * where each command otherwise uses a fresh runDir. Both the requirement and
150
- * the human attestation live here. Segments are pre-validated (planDigest is
149
+ * the human attestation live here. The planDigest parameter is still received
150
+ * and validated (it binds the attestation content), but the path itself is
151
+ * stable across plan versions — new plan requirements can atomically replace
152
+ * old ones in the same directory. Segments are pre-validated (planDigest is
151
153
  * 64-hex, plugin matches SAFE_ID_RE) and the resolved path is contained within
152
154
  * the authority base, so no path escape is possible. (Path-escape validation is
153
155
  * copied verbatim from kimiAuthorityDir, with codebuddy wording.)
@@ -168,7 +170,7 @@ export function codebuddyAuthorityDir(context, planDigest, plugin) {
168
170
  throw new Error(`codebuddy attestation authority requires a safe plugin id: "${plugin}"`);
169
171
  }
170
172
  const base = resolve(context.root, '.release-skill', 'codebuddy-attestations');
171
- const dir = resolve(base, planDigest, plugin);
173
+ const dir = resolve(base, plugin);
172
174
  const rel = relative(base, dir);
173
175
  const sep = process.platform === 'win32' ? '\\' : '/';
174
176
  if (
@@ -452,7 +454,7 @@ export async function executeCodeBuddyManualRequirement(action, context) {
452
454
 
453
455
  const ref = action.ref ?? `v${action.version}`;
454
456
 
455
- // (B) Stable, plan-digest-keyed authority dir, shared across
457
+ // (B) Stable plugin-level authority dir, shared across
456
458
  // publish/reconcile/verify run dirs.
457
459
  let attestationDir;
458
460
  try {
@@ -556,11 +558,17 @@ export async function executeCodeBuddyManualRequirement(action, context) {
556
558
  }
557
559
  const { createdAt: _existingCreatedAt, ...existingBody } = existing;
558
560
  if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
559
- return createResult({
560
- actionType,
561
- status: ActionStatus.EXECUTE_FAILED,
562
- error: 'existing codebuddy manual-install requirement conflicts with the current frozen action; refusing to overwrite',
563
- });
561
+ // 旧 plan 的 requirement 与新 plan 不同:允许原子替换(路径不再含 planDigest)。
562
+ // 但如果 planDigest 相同而内容不同,说明同一 plan 内的冻结动作不一致,仍失败关闭。
563
+ if (existing.planDigest === planDigest) {
564
+ return createResult({
565
+ actionType,
566
+ status: ActionStatus.EXECUTE_FAILED,
567
+ error: 'existing codebuddy manual-install requirement conflicts with the current frozen action (same planDigest); refusing to overwrite',
568
+ });
569
+ }
570
+ // 不同 planDigest:原子替换旧 plan 的 requirement
571
+ await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
564
572
  }
565
573
  } else {
566
574
  await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
@@ -85,9 +85,11 @@ export async function resolveCodexBoundPlanDigest(context) {
85
85
  /**
86
86
  * Authoritative, cross-run attestation directory for a codex fallback install.
87
87
  *
88
- * Lives at a stable root-fixed location keyed by the verified frozen plan
89
- * digest and plugin id:
90
- * <root>/.release-skill/codex-attestations/<planDigest>/<plugin>/
88
+ * Lives at a stable root-fixed location keyed by the plugin id:
89
+ * <root>/.release-skill/codex-attestations/<plugin>/
90
+ *
91
+ * The planDigest parameter is still received and validated (it binds the
92
+ * attestation content), but the path itself is stable across plan versions.
91
93
  *
92
94
  * @param {object} context - adapter context (needs `root`).
93
95
  * @param {string} planDigest - verified frozen plan digest (64-hex).
@@ -105,7 +107,7 @@ export function codexAuthorityDir(context, planDigest, plugin) {
105
107
  throw new Error(`codex attestation authority requires a safe plugin id: "${plugin}"`);
106
108
  }
107
109
  const base = resolve(context.root, '.release-skill', 'codex-attestations');
108
- const dir = resolve(base, planDigest, plugin);
110
+ const dir = resolve(base, plugin);
109
111
  const rel = relative(base, dir);
110
112
  const sep = process.platform === 'win32' ? '\\' : '/';
111
113
  if (
@@ -245,7 +247,7 @@ export async function executeCodexManualRequirement(action, context) {
245
247
 
246
248
  const ref = action.ref ?? `v${action.version}`;
247
249
 
248
- // Stable, plan-digest-keyed authority dir.
250
+ // Stable plugin-level authority dir.
249
251
  let attestationDir;
250
252
  try {
251
253
  attestationDir = codexAuthorityDir(context, planDigest, action.plugin);
@@ -341,11 +343,17 @@ export async function executeCodexManualRequirement(action, context) {
341
343
  }
342
344
  const { createdAt: _existingCreatedAt, ...existingBody } = existing;
343
345
  if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
344
- return createResult({
345
- actionType,
346
- status: ActionStatus.EXECUTE_FAILED,
347
- error: 'existing codex manual-install requirement conflicts with the current frozen action; refusing to overwrite',
348
- });
346
+ // 旧 plan 的 requirement 与新 plan 不同:允许原子替换(路径不再含 planDigest)。
347
+ // 但如果 planDigest 相同而内容不同,说明同一 plan 内的冻结动作不一致,仍失败关闭。
348
+ if (existing.planDigest === planDigest) {
349
+ return createResult({
350
+ actionType,
351
+ status: ActionStatus.EXECUTE_FAILED,
352
+ error: 'existing codex manual-install requirement conflicts with the current frozen action (same planDigest); refusing to overwrite',
353
+ });
354
+ }
355
+ // 不同 planDigest:原子替换旧 plan 的 requirement
356
+ await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
349
357
  }
350
358
  } else {
351
359
  await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
@@ -129,16 +129,18 @@ export function resolveBoundPlanDigest(context) {
129
129
  /**
130
130
  * Authoritative, cross-run attestation directory for a kimi install.
131
131
  *
132
- * Lives at a stable root-fixed location keyed by the verified frozen plan
133
- * digest and plugin id:
134
- * <root>/.release-skill/kimi-attestations/<planDigest>/<plugin>/
132
+ * Lives at a stable root-fixed location keyed by the plugin id:
133
+ * <root>/.release-skill/kimi-attestations/<plugin>/
135
134
  *
136
135
  * This survives the publish -> manual install -> reconcile -> verify chain,
137
136
  * where each command otherwise uses a fresh runDir (an attestation written to a
138
137
  * publish runDir would be invisible to reconcile/verify). Both the requirement
139
- * and the human attestation live here. The segments are pre-validated (planDigest
140
- * is 64-hex, plugin matches SAFE_ID_RE) and the resolved path is contained
141
- * within the authority base, so no path escape is possible.
138
+ * and the human attestation live here. The planDigest parameter is still
139
+ * received and validated (it binds the attestation content), but the path
140
+ * itself is stable across plan versions new plan requirements can atomically
141
+ * replace old ones in the same directory. The segments are pre-validated
142
+ * (planDigest is 64-hex, plugin matches SAFE_ID_RE) and the resolved path is
143
+ * contained within the authority base, so no path escape is possible.
142
144
  *
143
145
  * @param {object} context - adapter context (needs `root`).
144
146
  * @param {string} planDigest - verified frozen plan digest (64-hex).
@@ -156,7 +158,7 @@ export function kimiAuthorityDir(context, planDigest, plugin) {
156
158
  throw new Error(`kimi attestation authority requires a safe plugin id: "${plugin}"`);
157
159
  }
158
160
  const base = resolve(context.root, '.release-skill', 'kimi-attestations');
159
- const dir = resolve(base, planDigest, plugin);
161
+ const dir = resolve(base, plugin);
160
162
  const rel = relative(base, dir);
161
163
  const sep = process.platform === 'win32' ? '\\' : '/';
162
164
  if (
@@ -187,7 +189,7 @@ function buildKimiInstallUrl(repo, ref) {
187
189
  /**
188
190
  * 统一人工安装说明:面向 Kimi Code 的人工结果流程。
189
191
  *
190
- * @param {{installUrl:string, plugin:string, version:string, ref:string, attestationDir:string, requiresInstalledClosure:boolean, managedRoot:string|null}} p
192
+ * @param {{installUrl:string, plugin:string, version:string, ref:string, attestationDir:string, requiresInstalledClosure:boolean}} p
191
193
  * @returns {string[]}
192
194
  */
193
195
  function buildKimiManualInstructions({
@@ -197,16 +199,15 @@ function buildKimiManualInstructions({
197
199
  ref,
198
200
  attestationDir,
199
201
  requiresInstalledClosure,
200
- managedRoot,
201
202
  }) {
202
203
  return [
203
204
  `Kimi Code 没有可脚本化的插件安装命令行工具;安装是手动交互步骤。`,
204
205
  `1) publish 完成所有远端写入后进入 PUBLISHED 状态(自动化 Git 分支/标签、npm 和 GitHub Release 写入已完成)。此 kimi 检查点标记为需要人工安装。`,
205
- `2) ${requiresInstalledClosure ? `以 KIMI_CODE_HOME="${resolve(managedRoot, '..', '..')}" 启动 Kimi Code,然后` : '在 Kimi Code 中'}运行: /plugins install ${installUrl}(锁定到冻结 ref "${ref}",版本 ${version})。确认插件 "${plugin}" 的信任提示,然后运行 /plugins reload(或 /new)。`,
206
+ `2) 在 Kimi Code 中运行: /plugins install ${installUrl}(锁定到冻结 ref "${ref}",版本 ${version})。确认插件 "${plugin}" 的信任提示,然后运行 /plugins reload(或 /new)。`,
206
207
  `3) 将人工结果 JSON 写入: ${attestationDir}/${KIMI_ATTESTATION_FILE}`,
207
- ` 必填字段: platform="kimi", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)${requiresInstalledClosure ? ',installPath(实际安装后的插件目录,必须位于该证明目录的 kimi-home/plugins/managed/ 内)' : ''}`,
208
+ ` 必填字段: platform="kimi", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)${requiresInstalledClosure ? ',installPath(实际安装后的插件目录的真实路径)' : ''}`,
208
209
  ` 可选字段: note(备注)`,
209
- `4) 运行 release-skill reconcile(对账远端状态并跳过已完成步骤),然后 release-skill verify(从同一个计划摘要索引的权威目录读取结果,成功后 -> VERIFIED)。`,
210
+ `4) 若发布运行是 PARTIAL,先运行 release-skill reconcile;若已是 PUBLISHED,直接运行 release-skill verify。verify 从该插件的稳定证明目录读取并校验当前 planDigest,成功后 -> VERIFIED。`,
210
211
  ];
211
212
  }
212
213
 
@@ -433,7 +434,7 @@ export async function executeKimiManualRequirement(action, context) {
433
434
  const ref = action.ref ?? `v${action.version}`;
434
435
  const installUrl = buildKimiInstallUrl(action.repo, ref);
435
436
 
436
- // (B) Stable, plan-digest-keyed authority dir, shared across
437
+ // (B) Stable plugin-level authority dir, shared across
437
438
  // publish/reconcile/verify run dirs.
438
439
  let attestationDir;
439
440
  try {
@@ -447,9 +448,6 @@ export async function executeKimiManualRequirement(action, context) {
447
448
  }
448
449
 
449
450
  const requiresInstalledClosure = Boolean(context.plan?.skillResourceClosure);
450
- const managedRoot = requiresInstalledClosure
451
- ? resolve(attestationDir, 'kimi-home', 'plugins', 'managed')
452
- : null;
453
451
  const instructions = buildKimiManualInstructions({
454
452
  installUrl,
455
453
  plugin: action.plugin,
@@ -457,7 +455,6 @@ export async function executeKimiManualRequirement(action, context) {
457
455
  ref,
458
456
  attestationDir,
459
457
  requiresInstalledClosure,
460
- managedRoot,
461
458
  });
462
459
 
463
460
  // 统一 requirement 结构:不再包含隔离目录信息
@@ -481,7 +478,7 @@ export async function executeKimiManualRequirement(action, context) {
481
478
  actor: '<person who confirmed the install>',
482
479
  confirmedAt: '<ISO 8601 timestamp>',
483
480
  ...(requiresInstalledClosure
484
- ? { installPath: resolve(managedRoot, action.plugin) }
481
+ ? { installPath: '<actual installed plugin directory>' }
485
482
  : {}),
486
483
  note: '<optional note>',
487
484
  },
@@ -503,22 +500,6 @@ export async function executeKimiManualRequirement(action, context) {
503
500
  }
504
501
  }
505
502
 
506
- // New closure plans must scan an actual installed consumer tree. Create only
507
- // the isolated KIMI_CODE_HOME container; the interactive host remains the
508
- // sole owner of managed/<plugin>. Legacy plans retain the previous no-home
509
- // behavior byte-for-byte.
510
- if (requiresInstalledClosure) {
511
- try {
512
- await mkdir(managedRoot, { recursive: true, mode: 0o700 });
513
- } catch (mkdirErr) {
514
- return createResult({
515
- actionType,
516
- status: ActionStatus.EXECUTE_FAILED,
517
- error: `cannot create kimi resource-closure managed root: ${mkdirErr.message}`,
518
- });
519
- }
520
- }
521
-
522
503
  // Idempotent requirement write: an identical existing requirement is left
523
504
  // untouched; a divergent existing requirement fails closed (never silently
524
505
  // overwritten). `createdAt` is volatile and excluded from the comparison.
@@ -557,11 +538,17 @@ export async function executeKimiManualRequirement(action, context) {
557
538
  }
558
539
  const { createdAt: _existingCreatedAt, ...existingBody } = existing;
559
540
  if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
560
- return createResult({
561
- actionType,
562
- status: ActionStatus.EXECUTE_FAILED,
563
- error: 'existing kimi manual-install requirement conflicts with the current frozen action; refusing to overwrite',
564
- });
541
+ // 旧 plan 的 requirement 与新 plan 不同:允许原子替换(路径不再含 planDigest)。
542
+ // 但如果 planDigest 相同而内容不同,说明同一 plan 内的冻结动作不一致,仍失败关闭。
543
+ if (existing.planDigest === planDigest) {
544
+ return createResult({
545
+ actionType,
546
+ status: ActionStatus.EXECUTE_FAILED,
547
+ error: 'existing kimi manual-install requirement conflicts with the current frozen action (same planDigest); refusing to overwrite',
548
+ });
549
+ }
550
+ // 不同 planDigest:原子替换旧 plan 的 requirement
551
+ await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
565
552
  }
566
553
  } else {
567
554
  await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
@@ -547,7 +547,36 @@ async function runTarFromHandle(handle, args) {
547
547
  });
548
548
  }
549
549
 
550
- async function verifyNpmTarballContent({ snapshotDir, tarballBytes, tarballDir, expectedSnapshotDigest }) {
550
+ function validateNpmTarballListing(listOut) {
551
+ const listed = listOut.split(/\r?\n/).filter(Boolean);
552
+ if (listed.length === 0) {
553
+ throw frozenError('npm tarball contains no entries');
554
+ }
555
+
556
+ const seen = new Set();
557
+ for (const entry of listed) {
558
+ if (
559
+ entry.startsWith('/') || entry.includes('\\') ||
560
+ (!entry.startsWith('package/') && entry !== 'package')
561
+ ) {
562
+ throw frozenError('npm tarball contains an unsafe or unexpected path', { entry });
563
+ }
564
+ const segments = entry.split('/');
565
+ const pathSegments = entry.endsWith('/') ? segments.slice(0, -1) : segments;
566
+ if (
567
+ pathSegments.some((segment) => segment === '' || segment === '.' || segment === '..')
568
+ ) {
569
+ throw frozenError('npm tarball contains an unsafe or unexpected path', { entry });
570
+ }
571
+ const canonicalPath = entry.endsWith('/') ? entry.slice(0, -1) : entry;
572
+ if (seen.has(canonicalPath)) {
573
+ throw frozenError('npm tarball contains a duplicate path', { entry: canonicalPath });
574
+ }
575
+ seen.add(canonicalPath);
576
+ }
577
+ }
578
+
579
+ async function inspectNpmTarballContent({ tarballBytes, tarballDir }) {
551
580
  const listHandle = await createDetachedReadHandle(tarballBytes, tarballDir);
552
581
  let listOut;
553
582
  try {
@@ -555,13 +584,7 @@ async function verifyNpmTarballContent({ snapshotDir, tarballBytes, tarballDir,
555
584
  } finally {
556
585
  await listHandle.close();
557
586
  }
558
- const listed = listOut.split(/\r?\n/).filter(Boolean);
559
- if (listed.length === 0 || listed.some((entry) => (
560
- !entry.startsWith('package/') || entry.startsWith('/') || entry.includes('\\') ||
561
- entry.split('/').some((segment) => segment === '..')
562
- ))) {
563
- throw frozenError('npm tarball contains an unsafe or unexpected path');
564
- }
587
+ validateNpmTarballListing(listOut);
565
588
 
566
589
  const verifyDir = await mkdtemp(join(tarballDir, '.verify-'));
567
590
  try {
@@ -571,22 +594,31 @@ async function verifyNpmTarballContent({ snapshotDir, tarballBytes, tarballDir,
571
594
  } finally {
572
595
  await extractHandle.close();
573
596
  }
574
- const original = await computeFrozenSnapshot(snapshotDir);
575
- if (original.digest !== expectedSnapshotDigest) {
576
- throw frozenError('frozen snapshot changed while deriving npm tarball', {
577
- expectedDigest: expectedSnapshotDigest,
578
- observedDigest: original.digest,
579
- });
580
- }
581
- const packed = await computeFrozenSnapshot(join(verifyDir, 'package'));
582
- if (JSON.stringify(contentEntries(packed.entries)) !== JSON.stringify(contentEntries(original.entries))) {
583
- throw frozenError('npm tarball bytes do not match the sealed public snapshot');
584
- }
597
+ return await computeFrozenSnapshot(join(verifyDir, 'package'));
585
598
  } finally {
586
599
  await rm(verifyDir, { recursive: true, force: true });
587
600
  }
588
601
  }
589
602
 
603
+ async function verifyNpmTarballContent({ snapshotDir, tarballBytes, tarballDir, expectedSnapshotDigest }) {
604
+ const packed = await inspectNpmTarballContent({ tarballBytes, tarballDir });
605
+ const original = await computeFrozenSnapshot(snapshotDir);
606
+ if (original.digest !== expectedSnapshotDigest) {
607
+ throw frozenError('frozen snapshot changed while deriving npm tarball', {
608
+ expectedDigest: expectedSnapshotDigest,
609
+ observedDigest: original.digest,
610
+ });
611
+ }
612
+ if (JSON.stringify(contentEntries(packed.entries)) !== JSON.stringify(contentEntries(original.entries))) {
613
+ throw frozenError('npm tarball bytes do not match the sealed public snapshot');
614
+ }
615
+ }
616
+
617
+ export async function buildNpmTarballFileIndex({ tarballBytes, tarballDir }) {
618
+ const packed = await inspectNpmTarballContent({ tarballBytes, tarballDir });
619
+ return new Map(packed.entries.map((entry) => [entry.path, { type: entry.type }]));
620
+ }
621
+
590
622
  export async function buildFrozenNpmTarball({ snapshotDir, tarballDir, expectedSnapshotDigest, exec = execFile }) {
591
623
  await mkdir(tarballDir, { recursive: true });
592
624
  const { stdout } = await exec(