release-skill 0.2.4 → 0.2.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 (57) 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 +45 -0
  7. package/INSTALL.md +32 -8
  8. package/INSTALL.zh-CN.md +28 -6
  9. package/README.md +29 -15
  10. package/README.zh-CN.md +27 -15
  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 +2202 -708
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +158 -0
  16. package/adapters/claude/schemas/release-project.schema.json +6 -0
  17. package/adapters/claude/schemas/release-run.schema.json +150 -0
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +2202 -708
  20. package/adapters/codex/schemas/.render-manifest.json +6 -6
  21. package/adapters/codex/schemas/release-plan.schema.json +158 -0
  22. package/adapters/codex/schemas/release-project.schema.json +6 -0
  23. package/adapters/codex/schemas/release-run.schema.json +150 -0
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill.bundle.mjs +2202 -708
  26. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  27. package/adapters/kimi/schemas/release-plan.schema.json +158 -0
  28. package/adapters/kimi/schemas/release-project.schema.json +6 -0
  29. package/adapters/kimi/schemas/release-run.schema.json +150 -0
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2202 -708
  32. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  33. package/adapters/workbuddy/schemas/release-plan.schema.json +158 -0
  34. package/adapters/workbuddy/schemas/release-project.schema.json +6 -0
  35. package/adapters/workbuddy/schemas/release-run.schema.json +150 -0
  36. package/bin/release-skill.bundle.mjs +2202 -708
  37. package/package.json +1 -1
  38. package/references/.render-manifest.json +4 -4
  39. package/references/02-project-config.md +28 -2
  40. package/references/05-evidence-and-errors.md +6 -0
  41. package/schemas/.render-manifest.json +6 -6
  42. package/schemas/release-plan.schema.json +158 -0
  43. package/schemas/release-project.schema.json +6 -0
  44. package/schemas/release-run.schema.json +150 -0
  45. package/scripts/sync-public-files.mjs +20 -9
  46. package/src/adapters/plugin-marketplace.mjs +82 -3
  47. package/src/commands/prepare.mjs +232 -1
  48. package/src/commands/publish.mjs +134 -0
  49. package/src/commands/reconcile.mjs +19 -0
  50. package/src/commands/setup.mjs +26 -0
  51. package/src/commands/verify.mjs +183 -1
  52. package/src/core/errors.mjs +12 -0
  53. package/src/core/plan.mjs +29 -0
  54. package/src/core/skill-resource-closure.mjs +425 -0
  55. package/src/core/source-authority.mjs +547 -0
  56. package/src/platforms/codebuddy.mjs +16 -3
  57. package/src/platforms/kimi.mjs +36 -9
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Workspace source-authority content closure.
3
+ *
4
+ * The closure binds the workspace-relative source files that feed a public
5
+ * release. Publish compares the frozen entries with the configured remote
6
+ * default branch before any external write.
7
+ */
8
+
9
+ import { execFile as execFileCb } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { lstat, mkdtemp, readFile, readdir, realpath, rm } from 'node:fs/promises';
12
+ import { join, relative, resolve, sep } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+
15
+ import { canonicalJson, sha256Hex } from './digest.mjs';
16
+ import {
17
+ CONFIG_INVALID,
18
+ CONFIG_MISSING,
19
+ CONTENT_MISMATCH,
20
+ DIRTY_SOURCE_INPUT,
21
+ NOT_DEFAULT,
22
+ REF_MISSING,
23
+ REMOTE_UNAVAILABLE,
24
+ ReleaseError,
25
+ } from './errors.mjs';
26
+
27
+ const execFile = promisify(execFileCb);
28
+ const REPOSITORY_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u;
29
+ const GIT_MODE_RE = /^(?:100644|100755)$/u;
30
+
31
+ export const SOURCE_INPUT_ALGORITHM_VERSION = 1;
32
+
33
+ /**
34
+ * Compute the deterministic source-input closure for release units.
35
+ *
36
+ * `publicFiles.from` is workspace-root relative. `version.source` is relative
37
+ * to its release unit. Directories are recursively expanded. Symlinks and
38
+ * special files fail closed.
39
+ */
40
+ export async function computeSourceInputClosure({ units, root }) {
41
+ const realRoot = await realpath(resolve(root));
42
+ const entriesByPath = new Map();
43
+
44
+ for (const unit of units ?? []) {
45
+ for (const mapping of unit.publicFiles ?? []) {
46
+ if (typeof mapping?.from !== 'string' || mapping.from.length === 0) continue;
47
+ await collectPath({
48
+ absolutePath: resolveInside(realRoot, mapping.from),
49
+ root: realRoot,
50
+ entriesByPath,
51
+ });
52
+ }
53
+
54
+ const versionSource = unit.version?.source;
55
+ if (typeof versionSource === 'string' && versionSource.length > 0) {
56
+ const unitRoot = resolveInside(realRoot, unit.source ?? '.');
57
+ await collectPath({
58
+ absolutePath: resolveInside(unitRoot, versionSource, realRoot),
59
+ root: realRoot,
60
+ entriesByPath,
61
+ });
62
+ }
63
+ }
64
+
65
+ const entries = [...entriesByPath.values()]
66
+ .sort((left, right) => left.path.localeCompare(right.path));
67
+ const digest = computeEntriesDigest(entries);
68
+ return {
69
+ algorithmVersion: SOURCE_INPUT_ALGORITHM_VERSION,
70
+ entries,
71
+ digest,
72
+ };
73
+ }
74
+
75
+ function computeEntriesDigest(entries) {
76
+ return sha256Hex(canonicalJson(entries.map(({ path, digest, mode }) => ({
77
+ digest,
78
+ mode,
79
+ path,
80
+ }))));
81
+ }
82
+
83
+ async function collectPath({ absolutePath, root, entriesByPath }) {
84
+ let stat;
85
+ try {
86
+ stat = await lstat(absolutePath);
87
+ } catch (error) {
88
+ throw new ReleaseError(
89
+ CONFIG_INVALID,
90
+ `source-input closure cannot stat "${toRelative(root, absolutePath)}": ${error.message}`,
91
+ { cause: error.code ?? 'UNKNOWN', path: toRelative(root, absolutePath) },
92
+ );
93
+ }
94
+
95
+ const rel = toRelative(root, absolutePath);
96
+ if (stat.isSymbolicLink()) {
97
+ throw new ReleaseError(
98
+ CONFIG_INVALID,
99
+ `source-input closure rejects symlink "${rel}"`,
100
+ { path: rel },
101
+ );
102
+ }
103
+ const physicalPath = await realpath(absolutePath);
104
+ if (physicalPath !== absolutePath) {
105
+ throw new ReleaseError(
106
+ CONFIG_INVALID,
107
+ `source-input closure rejects symlinked ancestor for "${rel}"`,
108
+ { path: rel },
109
+ );
110
+ }
111
+ if (stat.isDirectory()) {
112
+ const children = await readdir(absolutePath, { withFileTypes: true });
113
+ children.sort((left, right) => left.name.localeCompare(right.name));
114
+ for (const child of children) {
115
+ await collectPath({
116
+ absolutePath: join(absolutePath, child.name),
117
+ root,
118
+ entriesByPath,
119
+ });
120
+ }
121
+ return;
122
+ }
123
+ if (!stat.isFile()) {
124
+ throw new ReleaseError(
125
+ CONFIG_INVALID,
126
+ `source-input closure rejects non-regular file "${rel}"`,
127
+ { path: rel },
128
+ );
129
+ }
130
+
131
+ const content = await readFile(absolutePath);
132
+ entriesByPath.set(rel, {
133
+ path: rel,
134
+ digest: sha256Hex(content),
135
+ mode: normalizeLocalGitMode(stat.mode),
136
+ });
137
+ }
138
+
139
+ function resolveInside(base, candidate, containmentRoot = base) {
140
+ const resolved = resolve(base, candidate);
141
+ const root = resolve(containmentRoot);
142
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
143
+ throw new ReleaseError(
144
+ CONFIG_INVALID,
145
+ `source-input closure path escapes workspace root: "${candidate}"`,
146
+ { path: candidate },
147
+ );
148
+ }
149
+ return resolved;
150
+ }
151
+
152
+ function toRelative(root, absolutePath) {
153
+ const rel = relative(root, absolutePath).split(sep).join('/');
154
+ if (!rel || rel === '.' || rel.startsWith('../') || rel === '..') {
155
+ throw new ReleaseError(
156
+ CONFIG_INVALID,
157
+ `source-input closure path is outside the workspace: "${absolutePath}"`,
158
+ { path: absolutePath },
159
+ );
160
+ }
161
+ return rel;
162
+ }
163
+
164
+ function normalizeLocalGitMode(mode) {
165
+ return (mode & 0o111) === 0 ? '100644' : '100755';
166
+ }
167
+
168
+ /**
169
+ * Check only frozen source-input paths for staged, unstaged or untracked
170
+ * changes. Unrelated dirty files remain allowed.
171
+ */
172
+ export async function checkSourceInputDirty({ closure, root, execFn = execFile }) {
173
+ const paths = (closure?.entries ?? []).map((entry) => entry.path);
174
+ if (paths.length === 0) return { dirty: false, dirtyPaths: [] };
175
+
176
+ let stdout;
177
+ try {
178
+ ({ stdout } = await execFn(
179
+ 'git',
180
+ [
181
+ 'status',
182
+ '--porcelain=v1',
183
+ '-z',
184
+ '--untracked-files=all',
185
+ '--ignored=matching',
186
+ '--',
187
+ ...paths,
188
+ ],
189
+ { cwd: root, encoding: 'utf8', shell: false },
190
+ ));
191
+ } catch (error) {
192
+ throw new ReleaseError(
193
+ DIRTY_SOURCE_INPUT,
194
+ `cannot check source-input dirty status: ${error.message}`,
195
+ { cause: error.code ?? 'UNKNOWN' },
196
+ );
197
+ }
198
+
199
+ const dirtyPaths = [];
200
+ const records = String(stdout).split('\0').filter(Boolean);
201
+ for (let index = 0; index < records.length; index += 1) {
202
+ const record = records[index];
203
+ if (record.length < 4) continue;
204
+ const status = record.slice(0, 2);
205
+ const recordPath = record.slice(3);
206
+ // Ignored inputs are still untracked inputs. A file explicitly selected
207
+ // for publication must not escape the prepare gate merely because a
208
+ // .gitignore rule hides it from ordinary status output.
209
+ dirtyPaths.push(recordPath);
210
+ if (status.startsWith('R') || status.startsWith('C')) index += 1;
211
+ }
212
+ return {
213
+ dirty: dirtyPaths.length > 0,
214
+ dirtyPaths: [...new Set(dirtyPaths)].sort(),
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Prove that the bytes copied into every frozen public snapshot came from
220
+ * the same source-input closure that will later be checked on the remote
221
+ * default branch.
222
+ *
223
+ * This closes the prepare-time interval between closure calculation and
224
+ * snapshot construction. The snapshot builder records the exact source
225
+ * path, content digest and source mode for every copied file.
226
+ */
227
+ export function verifySnapshotSourcesMatchClosure({ closure, unitResults }) {
228
+ if (!closure || !Array.isArray(closure.entries)) {
229
+ return failure(CONFIG_INVALID, 'source-input closure is missing');
230
+ }
231
+ const closureByPath = new Map(
232
+ closure.entries.map((entry) => [entry.path, entry]),
233
+ );
234
+ const mismatchedPaths = [];
235
+
236
+ for (const { manifest } of unitResults ?? []) {
237
+ for (const entry of manifest?.entries ?? []) {
238
+ const expected = closureByPath.get(entry.from);
239
+ const actualMode = normalizeSnapshotGitMode(entry.mode);
240
+ if (
241
+ !expected
242
+ || entry.hash !== expected.digest
243
+ || actualMode !== expected.mode
244
+ ) {
245
+ mismatchedPaths.push(entry.from);
246
+ }
247
+ }
248
+ }
249
+
250
+ if (mismatchedPaths.length > 0) {
251
+ const paths = [...new Set(mismatchedPaths)].sort();
252
+ return {
253
+ passed: false,
254
+ error: {
255
+ code: DIRTY_SOURCE_INPUT,
256
+ message: `frozen snapshots differ from ${paths.length} source-input closure file(s)`,
257
+ paths,
258
+ },
259
+ };
260
+ }
261
+ return {
262
+ passed: true,
263
+ observation: {
264
+ snapshotSourceCount: new Set(
265
+ (unitResults ?? []).flatMap(({ manifest }) => (
266
+ (manifest?.entries ?? []).map((entry) => entry.from)
267
+ )),
268
+ ).size,
269
+ },
270
+ };
271
+ }
272
+
273
+ function normalizeSnapshotGitMode(mode) {
274
+ if (typeof mode === 'string' && GIT_MODE_RE.test(mode)) return mode;
275
+ if (typeof mode === 'number') return normalizeLocalGitMode(mode);
276
+ return null;
277
+ }
278
+
279
+ /**
280
+ * Compare a frozen closure with a repository's actual remote default branch.
281
+ *
282
+ * Tests may inject `readRemoteFn(repo, branch, path)`; production uses one
283
+ * shallow fetch in a temporary bare repository and never mutates user refs.
284
+ */
285
+ export async function verifyRemoteSourceContent({
286
+ sourceRepository,
287
+ defaultBranch,
288
+ closure,
289
+ readRemoteFn,
290
+ execFn = execFile,
291
+ }) {
292
+ if (!REPOSITORY_RE.test(sourceRepository ?? '')) {
293
+ return failure(CONFIG_MISSING, 'project.sourceRepository must be an explicit GitHub owner/repo');
294
+ }
295
+ if (typeof defaultBranch !== 'string' || defaultBranch.length === 0) {
296
+ return failure(CONFIG_MISSING, 'project.defaultBranch must be an explicit branch name');
297
+ }
298
+ if (!closure || closure.algorithmVersion !== SOURCE_INPUT_ALGORITHM_VERSION) {
299
+ return failure(CONFIG_INVALID, 'source-input closure algorithm is unsupported');
300
+ }
301
+ if (!Array.isArray(closure.entries) || computeEntriesDigest(closure.entries) !== closure.digest) {
302
+ return failure(CONFIG_INVALID, 'source-input closure entries do not match the frozen digest');
303
+ }
304
+
305
+ if (readRemoteFn) {
306
+ return verifyWithInjectedReader({
307
+ closure,
308
+ defaultBranch,
309
+ readRemoteFn,
310
+ sourceRepository,
311
+ });
312
+ }
313
+ return verifyWithTemporaryGit({
314
+ closure,
315
+ defaultBranch,
316
+ execFn,
317
+ sourceRepository,
318
+ });
319
+ }
320
+
321
+ async function verifyWithInjectedReader({
322
+ closure,
323
+ defaultBranch,
324
+ readRemoteFn,
325
+ sourceRepository,
326
+ }) {
327
+ const mismatchedPaths = [];
328
+ for (const entry of closure.entries) {
329
+ let result;
330
+ try {
331
+ result = await readRemoteFn(sourceRepository, defaultBranch, entry.path);
332
+ } catch (error) {
333
+ return failure(REMOTE_UNAVAILABLE, error.message);
334
+ }
335
+ const classified = classifyRemoteStatus(result, sourceRepository, defaultBranch);
336
+ if (classified) return classified;
337
+ if (
338
+ sha256Hex(Buffer.isBuffer(result.content) ? result.content : Buffer.from(result.content))
339
+ !== entry.digest
340
+ || result.mode !== entry.mode
341
+ ) {
342
+ mismatchedPaths.push(entry.path);
343
+ }
344
+ }
345
+ return mismatchedPaths.length > 0
346
+ ? mismatch(defaultBranch, mismatchedPaths)
347
+ : {
348
+ passed: true,
349
+ observation: {
350
+ defaultBranch,
351
+ entryCount: closure.entries.length,
352
+ sourceRepository,
353
+ },
354
+ };
355
+ }
356
+
357
+ function classifyRemoteStatus(result, repository, branch) {
358
+ if (result?.status === 'ok') return null;
359
+ if (result?.status === 'ref_missing') {
360
+ return failure(
361
+ REF_MISSING,
362
+ result.error ?? `remote ref "${branch}" does not exist in "${repository}"`,
363
+ );
364
+ }
365
+ if (result?.status === 'not_default') {
366
+ return failure(
367
+ NOT_DEFAULT,
368
+ result.error ?? `"${branch}" is not the default branch of "${repository}"`,
369
+ );
370
+ }
371
+ return failure(
372
+ REMOTE_UNAVAILABLE,
373
+ result?.error ?? `remote source "${repository}" is unavailable`,
374
+ );
375
+ }
376
+
377
+ async function verifyWithTemporaryGit({
378
+ closure,
379
+ defaultBranch,
380
+ execFn,
381
+ sourceRepository,
382
+ }) {
383
+ const repositoryUrl = `https://github.com/${sourceRepository}.git`;
384
+ let observed;
385
+ try {
386
+ ({ stdout: observed } = await execFn(
387
+ 'git',
388
+ ['ls-remote', '--symref', repositoryUrl, 'HEAD', `refs/heads/${defaultBranch}`],
389
+ { encoding: 'utf8', shell: false, timeout: 60_000 },
390
+ ));
391
+ } catch (error) {
392
+ return failure(REMOTE_UNAVAILABLE, `cannot observe "${sourceRepository}": ${error.message}`);
393
+ }
394
+
395
+ const lines = String(observed).split(/\r?\n/u).filter(Boolean);
396
+ const headSymref = lines.find((line) => line.startsWith('ref: refs/heads/'));
397
+ const actualDefault = headSymref?.match(/^ref: refs\/heads\/(.+)\tHEAD$/u)?.[1] ?? null;
398
+ if (!actualDefault) {
399
+ return failure(REMOTE_UNAVAILABLE, `remote default branch is not observable for "${sourceRepository}"`);
400
+ }
401
+ if (actualDefault !== defaultBranch) {
402
+ return failure(
403
+ NOT_DEFAULT,
404
+ `configured defaultBranch "${defaultBranch}" does not match remote default "${actualDefault}"`,
405
+ );
406
+ }
407
+ const branchLine = lines.find((line) => line.endsWith(`\trefs/heads/${defaultBranch}`));
408
+ if (!branchLine) {
409
+ return failure(REF_MISSING, `remote ref "refs/heads/${defaultBranch}" does not exist`);
410
+ }
411
+ const tempRoot = await mkdtemp(join(tmpdir(), 'release-skill-source-authority-'));
412
+ try {
413
+ await execFn('git', ['init', '--bare', tempRoot], {
414
+ encoding: 'utf8',
415
+ shell: false,
416
+ timeout: 30_000,
417
+ });
418
+ await execFn(
419
+ 'git',
420
+ [
421
+ '-C',
422
+ tempRoot,
423
+ 'fetch',
424
+ '--depth=1',
425
+ repositoryUrl,
426
+ `refs/heads/${defaultBranch}:refs/source-authority/target`,
427
+ ],
428
+ { encoding: 'utf8', shell: false, timeout: 120_000 },
429
+ );
430
+ const { stdout: fetchedCommitOutput } = await execFn(
431
+ 'git',
432
+ ['-C', tempRoot, 'rev-parse', 'refs/source-authority/target'],
433
+ { encoding: 'utf8', shell: false, timeout: 30_000 },
434
+ );
435
+ const observedCommit = String(fetchedCommitOutput).trim();
436
+ const { stdout: treeOutput } = await execFn(
437
+ 'git',
438
+ ['-C', tempRoot, 'ls-tree', '-r', '-z', 'refs/source-authority/target'],
439
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: false, timeout: 30_000 },
440
+ );
441
+ const tree = parseLsTree(treeOutput);
442
+ const mismatchedPaths = [];
443
+ for (const expected of closure.entries) {
444
+ const actual = tree.get(expected.path);
445
+ if (!actual || actual.type !== 'blob' || actual.mode !== expected.mode) {
446
+ mismatchedPaths.push(expected.path);
447
+ continue;
448
+ }
449
+ const { stdout: content } = await execFn(
450
+ 'git',
451
+ ['-C', tempRoot, 'cat-file', 'blob', actual.objectId],
452
+ { encoding: null, maxBuffer: 64 * 1024 * 1024, shell: false, timeout: 30_000 },
453
+ );
454
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
455
+ if (sha256Hex(bytes) !== expected.digest) mismatchedPaths.push(expected.path);
456
+ }
457
+ return mismatchedPaths.length > 0
458
+ ? mismatch(defaultBranch, mismatchedPaths)
459
+ : {
460
+ passed: true,
461
+ observation: {
462
+ defaultBranch,
463
+ entryCount: closure.entries.length,
464
+ observedCommit,
465
+ sourceRepository,
466
+ },
467
+ };
468
+ } catch (error) {
469
+ return failure(REMOTE_UNAVAILABLE, `cannot read remote source tree: ${error.message}`);
470
+ } finally {
471
+ await rm(tempRoot, { force: true, recursive: true });
472
+ }
473
+ }
474
+
475
+ function parseLsTree(output) {
476
+ const result = new Map();
477
+ for (const record of String(output).split('\0').filter(Boolean)) {
478
+ const match = record.match(/^([0-9]{6}) ([a-z]+) ([0-9a-f]{40,64})\t(.+)$/u);
479
+ if (!match) continue;
480
+ result.set(match[4], {
481
+ mode: match[1],
482
+ objectId: match[3],
483
+ type: match[2],
484
+ });
485
+ }
486
+ return result;
487
+ }
488
+
489
+ function mismatch(defaultBranch, paths) {
490
+ const uniquePaths = [...new Set(paths)].sort();
491
+ return {
492
+ passed: false,
493
+ error: {
494
+ code: CONTENT_MISMATCH,
495
+ message: `remote default branch "${defaultBranch}" differs from ${uniquePaths.length} frozen source input(s)`,
496
+ paths: uniquePaths,
497
+ },
498
+ };
499
+ }
500
+
501
+ function failure(code, message) {
502
+ return { passed: false, error: { code, message } };
503
+ }
504
+
505
+ /** Validate a publish receipt against the frozen plan authority. */
506
+ export function verifySourceAuthorityReceipt({ plan, run }) {
507
+ const authority = plan.sourceAuthority;
508
+ if (!authority) return { passed: true };
509
+ const matching = (run.sourceAuthorityReceipts ?? []).find((receipt) => (
510
+ receipt.sourceRepository === authority.sourceRepository
511
+ && receipt.defaultBranch === authority.defaultBranch
512
+ && receipt.inputDigest === authority.inputDigest
513
+ && receipt.algorithmVersion === authority.algorithmVersion
514
+ && receipt.entryCount === authority.entries.length
515
+ && receipt.planDigest === plan.digest
516
+ && receipt.result === 'CONSISTENT'
517
+ ));
518
+ return matching
519
+ ? { passed: true, receipt: matching }
520
+ : {
521
+ passed: false,
522
+ reason: 'publish run has no CONSISTENT source-authority receipt bound to this plan digest',
523
+ };
524
+ }
525
+
526
+ /** Create the digest-bound receipt persisted by publish. */
527
+ export function createSourceAuthorityReceipt({
528
+ plan,
529
+ result,
530
+ observation,
531
+ mismatchedPaths,
532
+ clock = () => new Date().toISOString(),
533
+ }) {
534
+ const authority = plan.sourceAuthority;
535
+ return {
536
+ algorithmVersion: authority.algorithmVersion,
537
+ defaultBranch: authority.defaultBranch,
538
+ entryCount: authority.entries.length,
539
+ inputDigest: authority.inputDigest,
540
+ planDigest: plan.digest,
541
+ result,
542
+ sourceRepository: authority.sourceRepository,
543
+ verifiedAt: clock(),
544
+ ...(observation?.observedCommit ? { observedCommit: observation.observedCommit } : {}),
545
+ ...(mismatchedPaths?.length ? { mismatchedPaths: [...new Set(mismatchedPaths)].sort() } : {}),
546
+ };
547
+ }
@@ -183,16 +183,22 @@ export function codebuddyAuthorityDir(context, planDigest, plugin) {
183
183
  /**
184
184
  * 统一人工安装说明:面向 CodeBuddy / WorkBuddy 的人工结果流程。
185
185
  *
186
- * @param {{plugin:string, version:string, ref:string, attestationDir:string}} p
186
+ * @param {{plugin:string, version:string, ref:string, attestationDir:string, requiresInstalledClosure:boolean}} p
187
187
  * @returns {string[]}
188
188
  */
189
- function buildCodeBuddyManualInstructions({ plugin, version, ref, attestationDir }) {
189
+ function buildCodeBuddyManualInstructions({
190
+ plugin,
191
+ version,
192
+ ref,
193
+ attestationDir,
194
+ requiresInstalledClosure,
195
+ }) {
190
196
  return [
191
197
  `CodeBuddy/WorkBuddy 插件安装无法锁定冻结 ref(codebuddy CLI marketplace add/install 没有 ref 选项,跟踪默认分支),因此安装是需要人工结果证明的手动步骤。`,
192
198
  `1) publish 完成所有远端写入后进入 PUBLISHED 状态(自动化 Git 分支/标签、npm 和 GitHub Release 写入已完成)。此 codebuddy 检查点标记为需要人工安装。`,
193
199
  `2) 从统一市场 "${CODEBUDDY_MARKETPLACE_NAME}" (${CODEBUDDY_MARKETPLACE_SOURCE}) 安装 release-skill。确认安装的插件版本等于冻结版本 ${version}。`,
194
200
  `3) 将人工结果 JSON 写入: ${attestationDir}/${CODEBUDDY_ATTESTATION_FILE}`,
195
- ` 必填字段: platform="codebuddy", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)`,
201
+ ` 必填字段: platform="codebuddy", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)${requiresInstalledClosure ? ',installChannel("desktop" 或 "cli"),installPath(实际安装后的插件目录)' : ''}`,
196
202
  ` 可选字段: note(备注)`,
197
203
  `4) 运行 release-skill verify(从同一个计划摘要索引的权威目录读取结果,成功后 -> VERIFIED)。`,
198
204
  ];
@@ -464,6 +470,7 @@ export async function executeCodeBuddyManualRequirement(action, context) {
464
470
  version: action.version,
465
471
  ref,
466
472
  attestationDir,
473
+ requiresInstalledClosure: Boolean(context.plan?.skillResourceClosure),
467
474
  });
468
475
 
469
476
  // 统一 requirement 结构:不再包含隔离目录信息
@@ -485,6 +492,12 @@ export async function executeCodeBuddyManualRequirement(action, context) {
485
492
  result: '<"passed" or "failed">',
486
493
  actor: '<person who confirmed the install>',
487
494
  confirmedAt: '<ISO 8601 timestamp>',
495
+ ...(context.plan?.skillResourceClosure
496
+ ? {
497
+ installChannel: '<"desktop" or "cli">',
498
+ installPath: `<actual .workbuddy/plugins/marketplaces/${CODEBUDDY_MARKETPLACE_NAME}/plugins/${action.plugin} directory>`,
499
+ }
500
+ : {}),
488
501
  note: '<optional note>',
489
502
  },
490
503
  instructions,
@@ -187,16 +187,24 @@ function buildKimiInstallUrl(repo, ref) {
187
187
  /**
188
188
  * 统一人工安装说明:面向 Kimi Code 的人工结果流程。
189
189
  *
190
- * @param {{installUrl:string, plugin:string, version:string, ref:string, attestationDir:string}} p
190
+ * @param {{installUrl:string, plugin:string, version:string, ref:string, attestationDir:string, requiresInstalledClosure:boolean, managedRoot:string|null}} p
191
191
  * @returns {string[]}
192
192
  */
193
- function buildKimiManualInstructions({ installUrl, plugin, version, ref, attestationDir }) {
193
+ function buildKimiManualInstructions({
194
+ installUrl,
195
+ plugin,
196
+ version,
197
+ ref,
198
+ attestationDir,
199
+ requiresInstalledClosure,
200
+ managedRoot,
201
+ }) {
194
202
  return [
195
203
  `Kimi Code 没有可脚本化的插件安装命令行工具;安装是手动交互步骤。`,
196
204
  `1) publish 完成所有远端写入后进入 PUBLISHED 状态(自动化 Git 分支/标签、npm 和 GitHub Release 写入已完成)。此 kimi 检查点标记为需要人工安装。`,
197
- `2) 在 Kimi Code 中运行: /plugins install ${installUrl}(锁定到冻结 ref "${ref}",版本 ${version})。确认插件 "${plugin}" 的信任提示,然后运行 /plugins reload(或 /new)。`,
205
+ `2) ${requiresInstalledClosure ? `以 KIMI_CODE_HOME="${resolve(managedRoot, '..', '..')}" 启动 Kimi Code,然后` : '在 Kimi Code 中'}运行: /plugins install ${installUrl}(锁定到冻结 ref "${ref}",版本 ${version})。确认插件 "${plugin}" 的信任提示,然后运行 /plugins reload(或 /new)。`,
198
206
  `3) 将人工结果 JSON 写入: ${attestationDir}/${KIMI_ATTESTATION_FILE}`,
199
- ` 必填字段: platform="kimi", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)`,
207
+ ` 必填字段: platform="kimi", version, planDigest(冻结计划摘要), result("passed" 或 "failed"), actor(确认人), confirmedAt(ISO 8601 时间戳)${requiresInstalledClosure ? ',installPath(实际安装后的插件目录,必须位于该证明目录的 kimi-home/plugins/managed/ 内)' : ''}`,
200
208
  ` 可选字段: note(备注)`,
201
209
  `4) 运行 release-skill reconcile(对账远端状态并跳过已完成步骤),然后 release-skill verify(从同一个计划摘要索引的权威目录读取结果,成功后 -> VERIFIED)。`,
202
210
  ];
@@ -438,12 +446,18 @@ export async function executeKimiManualRequirement(action, context) {
438
446
  });
439
447
  }
440
448
 
449
+ const requiresInstalledClosure = Boolean(context.plan?.skillResourceClosure);
450
+ const managedRoot = requiresInstalledClosure
451
+ ? resolve(attestationDir, 'kimi-home', 'plugins', 'managed')
452
+ : null;
441
453
  const instructions = buildKimiManualInstructions({
442
454
  installUrl,
443
455
  plugin: action.plugin,
444
456
  version: action.version,
445
457
  ref,
446
458
  attestationDir,
459
+ requiresInstalledClosure,
460
+ managedRoot,
447
461
  });
448
462
 
449
463
  // 统一 requirement 结构:不再包含隔离目录信息
@@ -466,6 +480,9 @@ export async function executeKimiManualRequirement(action, context) {
466
480
  result: '<"passed" or "failed">',
467
481
  actor: '<person who confirmed the install>',
468
482
  confirmedAt: '<ISO 8601 timestamp>',
483
+ ...(requiresInstalledClosure
484
+ ? { installPath: resolve(managedRoot, action.plugin) }
485
+ : {}),
469
486
  note: '<optional note>',
470
487
  },
471
488
  instructions,
@@ -486,11 +503,21 @@ export async function executeKimiManualRequirement(action, context) {
486
503
  }
487
504
  }
488
505
 
489
- // New manual format: execute only writes the requirement file.
490
- // The managed home directory (kimi-home/plugins/managed) is NOT created here.
491
- // Only old-format attestations with installPath trigger managed home verification
492
- // in the observe path. Creating it unconditionally would violate the invariant
493
- // that new-format receipts do not create isolated installation artifacts.
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
+ }
494
521
 
495
522
  // Idempotent requirement write: an identical existing requirement is left
496
523
  // untouched; a divergent existing requirement fails closed (never silently