release-skill 0.9.18 → 0.9.19

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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +34 -0
  8. package/INSTALL.md +12 -2
  9. package/INSTALL.zh-CN.md +10 -3
  10. package/README.md +27 -17
  11. package/README.zh-CN.md +19 -17
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +924 -378
  14. package/adapters/claude/skills/release-finish/SKILL.md +30 -4
  15. package/adapters/claude/skills/release-help/SKILL.md +44 -101
  16. package/adapters/claude/skills/release-verify/SKILL.md +2 -2
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +924 -378
  19. package/adapters/codex/skills/release-finish/SKILL.md +30 -4
  20. package/adapters/codex/skills/release-help/SKILL.md +46 -103
  21. package/adapters/codex/skills/release-verify/SKILL.md +2 -2
  22. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  23. package/adapters/cursor/bin/release-skill.bundle.mjs +924 -378
  24. package/adapters/cursor/skills/release-finish/SKILL.md +30 -4
  25. package/adapters/cursor/skills/release-help/SKILL.md +46 -103
  26. package/adapters/cursor/skills/release-verify/SKILL.md +2 -2
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +924 -378
  29. package/adapters/kimi/skills/release-finish/SKILL.md +30 -4
  30. package/adapters/kimi/skills/release-help/SKILL.md +46 -103
  31. package/adapters/kimi/skills/release-verify/SKILL.md +2 -2
  32. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  33. package/adapters/qoder/bin/release-skill.bundle.mjs +924 -378
  34. package/adapters/qoder/skills/release-finish/SKILL.md +30 -4
  35. package/adapters/qoder/skills/release-help/SKILL.md +46 -103
  36. package/adapters/qoder/skills/release-verify/SKILL.md +2 -2
  37. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  38. package/adapters/workbuddy/bin/release-skill.bundle.mjs +924 -378
  39. package/adapters/workbuddy/skills/release-finish/SKILL.md +30 -4
  40. package/adapters/workbuddy/skills/release-help/SKILL.md +44 -101
  41. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -2
  42. package/bin/release-skill-cli.mjs +90 -14
  43. package/bin/release-skill.bundle.mjs +924 -378
  44. package/package.json +1 -1
  45. package/platform-manifest.json +4 -4
  46. package/references/02-project-config.md +4 -0
  47. package/skills/release-finish/SKILL.md +30 -4
  48. package/skills/release-help/SKILL.md +44 -101
  49. package/skills/release-verify/SKILL.md +2 -2
  50. package/skills-src/release-finish/SKILL.md +30 -4
  51. package/skills-src/release-help/SKILL.md +44 -101
  52. package/skills-src/release-verify/SKILL.md +2 -2
  53. package/src/commands/post-release-finish.mjs +448 -0
  54. package/src/commands/post-release-local.mjs +22 -2
  55. package/src/commands/ship.mjs +1 -0
@@ -0,0 +1,448 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, dirname, isAbsolute, normalize, resolve } from 'node:path';
3
+
4
+ import { readFileStrict } from 'skill-family-harness-node';
5
+
6
+ import { loadProjectConfig } from '../core/config.mjs';
7
+ import {
8
+ derivePostReleaseChecklist,
9
+ runLocalFinishCommand,
10
+ updateLocalHostPlugins,
11
+ } from './post-release-local.mjs';
12
+
13
+ const STEP_STATUSES = new Set(['COMPLETE', 'PENDING', 'FAILED', 'SKIPPED']);
14
+ const FEEDBACK_FIELDS = new Set(['planDigest', 'configDigest', 'projectRoot', 'merge', 'hosts', 'setup']);
15
+ const MERGE_FIELDS = new Set(['outcome', 'summary']);
16
+ const HOST_FIELDS = new Set(['unitId', 'host', 'installation', 'loaded', 'plugin', 'version', 'skillFile', 'summary']);
17
+ const SETUP_FIELDS = new Set(['host', 'unitId', 'skillFile', 'outcome', 'summary']);
18
+
19
+ function fail(message) {
20
+ const error = new Error(message);
21
+ error.code = 'POST_RELEASE_FINISH_INVALID';
22
+ error.exitCode = 1;
23
+ throw error;
24
+ }
25
+
26
+ function assertPlainObject(value, label) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be an object`);
28
+ }
29
+
30
+ function assertClosed(value, allowed, label) {
31
+ assertPlainObject(value, label);
32
+ const unknown = Object.keys(value).filter((field) => !allowed.has(field));
33
+ if (unknown.length > 0) fail(`${label} contains unknown fields: ${unknown.join(', ')}`);
34
+ }
35
+
36
+ function nonEmptyString(value, label) {
37
+ if (typeof value !== 'string' || value.trim().length === 0) fail(`${label} must be a non-empty string`);
38
+ return value;
39
+ }
40
+
41
+ function normalizedAbsolutePath(value, label) {
42
+ nonEmptyString(value, label);
43
+ if (!isAbsolute(value) || normalize(value) !== value) fail(`${label} must be a normalized absolute path`);
44
+ return value;
45
+ }
46
+
47
+ async function readFeedback(feedbackPath) {
48
+ if (feedbackPath === undefined) return null;
49
+ normalizedAbsolutePath(feedbackPath, 'finish feedback path');
50
+ let receipt;
51
+ try {
52
+ receipt = await readFileStrict(dirname(feedbackPath), basename(feedbackPath), { encoding: 'utf8' });
53
+ } catch (cause) {
54
+ fail(`cannot strictly read finish feedback: ${cause.message}`);
55
+ }
56
+ let value;
57
+ try {
58
+ value = JSON.parse(receipt.content);
59
+ } catch (cause) {
60
+ fail(`finish feedback is not valid JSON: ${cause.message}`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ function targetKey(unitId, host) {
66
+ return `${unitId}\u0000${host}`;
67
+ }
68
+
69
+ function validateFeedback(raw, { projectRoot, planDigest, configDigest, selectedTargets, setupSkill }) {
70
+ if (raw === null) return { merge: null, hosts: [], setup: null };
71
+ assertClosed(raw, FEEDBACK_FIELDS, 'finish feedback');
72
+ for (const field of ['planDigest', 'configDigest', 'projectRoot']) nonEmptyString(raw[field], `finish feedback.${field}`);
73
+ if (raw.planDigest !== planDigest) fail('finish feedback planDigest does not match the frozen plan');
74
+ if (raw.configDigest !== configDigest) fail('finish feedback configDigest does not match the current project configuration');
75
+ if (normalizedAbsolutePath(raw.projectRoot, 'finish feedback.projectRoot') !== projectRoot) {
76
+ fail('finish feedback projectRoot does not match the current project root');
77
+ }
78
+
79
+ let merge = null;
80
+ if (raw.merge !== undefined) {
81
+ assertClosed(raw.merge, MERGE_FIELDS, 'finish feedback.merge');
82
+ if (!['completed', 'skipped', 'pending'].includes(raw.merge.outcome)) fail('finish feedback.merge.outcome is invalid');
83
+ nonEmptyString(raw.merge.summary, 'finish feedback.merge.summary');
84
+ merge = { ...raw.merge, basis: 'agent-reported' };
85
+ }
86
+
87
+ const targetsByKey = new Map(selectedTargets.map((target) => [targetKey(target.unitId, target.host), target]));
88
+ const hosts = [];
89
+ const seen = new Set();
90
+ if (raw.hosts !== undefined && !Array.isArray(raw.hosts)) fail('finish feedback.hosts must be an array');
91
+ for (const [index, host] of (raw.hosts ?? []).entries()) {
92
+ assertClosed(host, HOST_FIELDS, `finish feedback.hosts[${index}]`);
93
+ for (const field of ['unitId', 'host', 'plugin', 'version', 'summary']) {
94
+ nonEmptyString(host[field], `finish feedback.hosts[${index}].${field}`);
95
+ }
96
+ if (!['current', 'pending', 'failed'].includes(host.installation)) fail(`finish feedback.hosts[${index}].installation is invalid`);
97
+ if (typeof host.loaded !== 'boolean') fail(`finish feedback.hosts[${index}].loaded must be boolean`);
98
+ if (host.installation !== 'current' && host.loaded) fail(`finish feedback.hosts[${index}] cannot be loaded unless installation is current`);
99
+ if (host.skillFile !== undefined) normalizedAbsolutePath(host.skillFile, `finish feedback.hosts[${index}].skillFile`);
100
+ const key = targetKey(host.unitId, host.host);
101
+ if (seen.has(key)) fail(`finish feedback contains duplicate host result for ${host.unitId}/${host.host}`);
102
+ seen.add(key);
103
+ const target = targetsByKey.get(key);
104
+ if (!target) fail(`finish feedback host ${host.unitId}/${host.host} is outside the selected frozen targets`);
105
+ if (host.plugin !== target.plugin || host.version !== target.version) {
106
+ fail(`finish feedback host ${host.unitId}/${host.host} does not match the frozen plugin identity`);
107
+ }
108
+ hosts.push({ ...host, basis: 'agent-reported' });
109
+ }
110
+
111
+ let setup = null;
112
+ if (raw.setup !== undefined) {
113
+ if (!setupSkill) fail('finish feedback.setup is not allowed when releaseFinish.setupSkill is not configured');
114
+ assertClosed(raw.setup, SETUP_FIELDS, 'finish feedback.setup');
115
+ for (const field of ['host', 'unitId', 'skillFile', 'summary']) nonEmptyString(raw.setup[field], `finish feedback.setup.${field}`);
116
+ normalizedAbsolutePath(raw.setup.skillFile, 'finish feedback.setup.skillFile');
117
+ if (!['completed', 'pending', 'failed'].includes(raw.setup.outcome)) fail('finish feedback.setup.outcome is invalid');
118
+ const host = hosts.find((entry) => entry.host === raw.setup.host && entry.unitId === raw.setup.unitId);
119
+ if (!host || host.installation !== 'current' || host.loaded !== true || host.skillFile !== raw.setup.skillFile) {
120
+ fail('finish feedback.setup is not bound to one selected, current, loaded host and matching skillFile');
121
+ }
122
+ setup = { ...raw.setup, basis: 'agent-reported' };
123
+ }
124
+ return { merge, hosts, setup };
125
+ }
126
+
127
+ function step(status, summary, extra = {}) {
128
+ if (!STEP_STATUSES.has(status)) throw new Error(`unknown finish step status: ${status}`);
129
+ return { status, summary, ...extra };
130
+ }
131
+
132
+ function resultToInstallation(result, target) {
133
+ if (!result) return null;
134
+ if (['UPDATED', 'ALREADY_CURRENT'].includes(result.status) && result.version !== target.version) {
135
+ return {
136
+ unitId: target.unitId, host: target.host, plugin: target.plugin, version: target.version,
137
+ installation: 'failed', loaded: false,
138
+ summary: `宿主更新结果版本 ${result.version ?? '(missing)'} 与冻结版本 ${target.version} 不一致。`,
139
+ basis: 'script-observed', updateStatus: result.status,
140
+ };
141
+ }
142
+ if (['UPDATED', 'ALREADY_CURRENT'].includes(result.status)) {
143
+ return {
144
+ unitId: target.unitId,
145
+ host: target.host,
146
+ plugin: target.plugin,
147
+ version: target.version,
148
+ installation: 'current',
149
+ loaded: false,
150
+ summary: result.status === 'UPDATED'
151
+ ? '脚本已更新并复验安装载荷;仍需由当前智能体确认宿主实际加载。'
152
+ : '脚本已确认安装载荷与冻结版本一致;仍需由当前智能体确认宿主实际加载。',
153
+ basis: 'script-observed',
154
+ updateStatus: result.status,
155
+ ...(result.restartRequired ? { restartRequired: true } : {}),
156
+ };
157
+ }
158
+ return {
159
+ unitId: target.unitId,
160
+ host: target.host,
161
+ plugin: target.plugin,
162
+ version: target.version,
163
+ installation: result.status === 'FAILED' ? 'failed' : 'pending',
164
+ loaded: false,
165
+ summary: result.reason ?? result.error ?? `宿主更新结果为 ${result.status}。`,
166
+ basis: 'script-observed',
167
+ updateStatus: result.status,
168
+ };
169
+ }
170
+
171
+ function gitReadEnvironment() {
172
+ const env = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
173
+ for (const key of ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL']) {
174
+ if (process.env[key] !== undefined) env[key] = process.env[key];
175
+ }
176
+ return env;
177
+ }
178
+
179
+ async function observeGitCommand({ root, args, run }) {
180
+ const argv = ['git', ...args];
181
+ try {
182
+ const result = await run('git', args, { cwd: root, env: gitReadEnvironment() });
183
+ return { argv, status: 'SUCCEEDED', exitStatus: 0, stdout: result.stdout, stderr: result.stderr };
184
+ } catch (cause) {
185
+ return {
186
+ argv,
187
+ status: 'FAILED',
188
+ exitStatus: cause?.exitStatus ?? cause?.code ?? null,
189
+ stdout: cause?.stdout ?? cause?.foundationStdout ?? '',
190
+ stderr: cause?.stderr ?? cause?.foundationStderr ?? '',
191
+ message: cause?.message ?? String(cause),
192
+ };
193
+ }
194
+ }
195
+
196
+ async function inspectSourceBranch({ root, config, run }) {
197
+ const policy = config.releaseFinish?.sourceBranchCheck ?? 'remind';
198
+ const targetBranch = config.project.defaultBranch;
199
+ if (policy === 'skip') {
200
+ return step('SKIPPED', '项目配置已关闭源码分支检查。', { policy, targetBranch, basis: 'script-observed' });
201
+ }
202
+ const branchResult = await observeGitCommand({ root, args: ['branch', '--show-current'], run });
203
+ const statusResult = await observeGitCommand({ root, args: ['status', '--short', '--branch'], run });
204
+ const commands = [branchResult, statusResult];
205
+ if (commands.some((command) => command.status === 'FAILED')) {
206
+ return step('FAILED', '源码分支只读检查至少一条 Git 命令失败。', {
207
+ policy, targetBranch, commands, basis: 'script-observed',
208
+ });
209
+ }
210
+ try {
211
+ const currentBranch = branchResult.stdout.trim();
212
+ const lines = statusResult.stdout.replace(/\r/gu, '').split('\n').filter(Boolean);
213
+ const tracking = lines[0]?.startsWith('## ') ? lines[0].slice(3) : '';
214
+ const changed = (lines[0]?.startsWith('## ') ? lines.slice(1) : lines).length > 0;
215
+ const detached = currentBranch.length === 0;
216
+ const aligned = !detached && currentBranch === targetBranch;
217
+ return step('COMPLETE', detached
218
+ ? '已完成只读检查;当前为 detached HEAD,未猜测后续 Git 操作。'
219
+ : aligned
220
+ ? `已完成只读检查;当前分支为目标分支 ${targetBranch}。`
221
+ : `已完成只读检查;当前分支 ${currentBranch} 与目标分支 ${targetBranch} 不同。`, {
222
+ policy,
223
+ targetBranch,
224
+ currentBranch: detached ? null : currentBranch,
225
+ detached,
226
+ aligned,
227
+ changed,
228
+ tracking: tracking || null,
229
+ rawStatus: statusResult.stdout,
230
+ commands,
231
+ basis: 'script-observed',
232
+ });
233
+ } catch (cause) {
234
+ return step('FAILED', `源码分支只读检查失败:${cause.message}`, {
235
+ policy, targetBranch, commands, basis: 'script-observed',
236
+ });
237
+ }
238
+ }
239
+
240
+ function finishStatus(steps) {
241
+ const statuses = Object.values(steps).map((entry) => entry.status);
242
+ if (statuses.includes('FAILED')) return 'FAILED';
243
+ if (statuses.includes('PENDING')) return 'PENDING';
244
+ return 'COMPLETE';
245
+ }
246
+
247
+ export async function runPostReleaseFinish({
248
+ root = process.cwd(),
249
+ plan,
250
+ planPath,
251
+ runPath,
252
+ selectedHosts = [],
253
+ updateRequested = false,
254
+ skipLocalHosts = false,
255
+ confirmPlanDigest,
256
+ cursorPluginsRoot,
257
+ feedbackPath,
258
+ updateLocalHostPluginsFn = updateLocalHostPlugins,
259
+ run = runLocalFinishCommand,
260
+ } = {}) {
261
+ const projectRoot = await realpath(resolve(root));
262
+ const loaded = await loadProjectConfig({ root: projectRoot });
263
+ const checklist = derivePostReleaseChecklist(plan, {
264
+ root: projectRoot,
265
+ planPath,
266
+ runPath,
267
+ postVerifyComplete: true,
268
+ });
269
+ const selected = new Set(selectedHosts);
270
+ const unknownHosts = [...selected].filter((host) => !checklist.localHostUpdate.hosts.includes(host));
271
+ if (unknownHosts.length > 0) fail(`selected hosts are not declared by the plan: ${unknownHosts.join(', ')}`);
272
+ const selectedTargets = checklist.localHostUpdate.targets.filter((target) => selected.has(target.host));
273
+ const feedback = validateFeedback(await readFeedback(feedbackPath), {
274
+ projectRoot,
275
+ planDigest: plan.digest,
276
+ configDigest: loaded.configDigest,
277
+ selectedTargets,
278
+ setupSkill: loaded.config.releaseFinish?.setupSkill,
279
+ });
280
+
281
+ let update = null;
282
+ if (updateRequested) {
283
+ update = await updateLocalHostPluginsFn({
284
+ planPath,
285
+ runPath,
286
+ root: projectRoot,
287
+ confirmPlanDigest,
288
+ selectedHosts,
289
+ cursorPluginsRoot,
290
+ });
291
+ }
292
+
293
+ const nextActions = [];
294
+ let mergeStep;
295
+ if (!checklist.merge.promptRequired) {
296
+ mergeStep = step('COMPLETE', '发布流程已覆盖分支推进,无需额外合并决定。', { basis: 'script-observed' });
297
+ } else if (!feedback.merge) {
298
+ mergeStep = step('PENDING', '尚未提供剩余发布分支的处理结果。');
299
+ nextActions.push({ type: 'decide-merge', units: checklist.merge.units });
300
+ } else {
301
+ const status = feedback.merge.outcome === 'completed' ? 'COMPLETE'
302
+ : feedback.merge.outcome === 'skipped' ? 'SKIPPED' : 'PENDING';
303
+ mergeStep = step(status, feedback.merge.summary, { basis: feedback.merge.basis, outcome: feedback.merge.outcome });
304
+ if (status === 'PENDING') nextActions.push({ type: 'decide-merge', units: checklist.merge.units });
305
+ }
306
+
307
+ const observations = [];
308
+ const feedbackByKey = new Map(feedback.hosts.map((entry) => [targetKey(entry.unitId, entry.host), entry]));
309
+ const updateByKey = new Map((update?.results ?? []).map((entry) => [targetKey(entry.unitId, entry.host), entry]));
310
+ const updateKeepsFeedback = !updateRequested || selectedTargets.every((target) => {
311
+ const result = updateByKey.get(targetKey(target.unitId, target.host));
312
+ return result?.status === 'ALREADY_CURRENT' && result.version === target.version;
313
+ });
314
+ for (const target of selectedTargets) {
315
+ const reported = feedbackByKey.get(targetKey(target.unitId, target.host));
316
+ const updateResult = updateByKey.get(targetKey(target.unitId, target.host));
317
+ const scriptObservation = resultToInstallation(updateResult, target);
318
+ if (!scriptObservation) {
319
+ if (reported) observations.push(reported);
320
+ continue;
321
+ }
322
+ if (scriptObservation.installation === 'current'
323
+ && scriptObservation.updateStatus === 'ALREADY_CURRENT'
324
+ && reported?.installation === 'current') {
325
+ observations.push({
326
+ ...scriptObservation,
327
+ loaded: reported.loaded,
328
+ ...(reported.skillFile ? { skillFile: reported.skillFile } : {}),
329
+ loadSummary: reported.summary,
330
+ loadBasis: 'agent-reported',
331
+ });
332
+ } else {
333
+ observations.push(scriptObservation);
334
+ }
335
+ }
336
+
337
+ let hostUpdateStep;
338
+ let hostLoadStep;
339
+ const hasTargets = checklist.localHostUpdate.targets.length > 0;
340
+ if (skipLocalHosts) {
341
+ hostUpdateStep = step('SKIPPED', '调用者已明确跳过本轮宿主更新。');
342
+ hostLoadStep = step('SKIPPED', '本轮跳过宿主更新,因此不检查目标插件加载。');
343
+ } else if (!hasTargets) {
344
+ hostUpdateStep = step('SKIPPED', '冻结计划没有适用的本机宿主目标。');
345
+ hostLoadStep = step('SKIPPED', '没有适用的宿主加载目标。');
346
+ } else if (selectedTargets.length === 0) {
347
+ hostUpdateStep = step('PENDING', '尚未选择本轮要处理的宿主。', { availableHosts: checklist.localHostUpdate.hosts });
348
+ hostLoadStep = step('PENDING', '需先选择宿主并确认安装结果。');
349
+ nextActions.push({ type: 'choose-local-hosts', hosts: checklist.localHostUpdate.hosts, targets: checklist.localHostUpdate.targets });
350
+ } else {
351
+ const failed = observations.filter((entry) => entry.installation === 'failed');
352
+ const current = observations.filter((entry) => entry.installation === 'current');
353
+ const missing = selectedTargets.filter((target) => !observations.some((entry) => entry.unitId === target.unitId && entry.host === target.host));
354
+ const pending = observations.filter((entry) => entry.installation === 'pending');
355
+ hostUpdateStep = step(failed.length > 0 ? 'FAILED' : (missing.length > 0 || pending.length > 0 ? 'PENDING' : 'COMPLETE'),
356
+ failed.length > 0 ? '至少一个所选宿主的安装或更新失败。'
357
+ : missing.length > 0 || pending.length > 0 ? '部分所选宿主缺少安装结果或仍待处理。'
358
+ : '所选宿主的安装均已确认为目标版本。',
359
+ { observations });
360
+ const unloaded = observations.filter((entry) => entry.installation === 'current' && entry.loaded !== true);
361
+ const loadedHosts = observations.filter((entry) => entry.installation === 'current' && entry.loaded === true);
362
+ hostLoadStep = step(failed.length > 0 ? 'FAILED' : (loadedHosts.length !== selectedTargets.length ? 'PENDING' : 'COMPLETE'),
363
+ failed.length > 0 ? '宿主安装失败,无法完成加载确认。'
364
+ : loadedHosts.length !== selectedTargets.length ? '尚未确认全部所选宿主已实际加载目标插件和入口。'
365
+ : '当前智能体已报告全部所选宿主加载目标入口。',
366
+ { observations });
367
+ if (missing.length > 0 || pending.length > 0) nextActions.push({ type: 'observe-host-installation', targets: [...missing, ...pending] });
368
+ if (unloaded.length > 0) nextActions.push({ type: 'check-host-load', projectRoot, observations: unloaded });
369
+ }
370
+
371
+ const setupSkill = loaded.config.releaseFinish?.setupSkill;
372
+ const candidates = observations.filter((entry) => (
373
+ entry.installation === 'current' && entry.loaded === true && typeof entry.skillFile === 'string'
374
+ ));
375
+ const candidateOwners = new Set(candidates.map((entry) => `${entry.plugin}\u0000${entry.version}\u0000${entry.skillFile}`));
376
+ const effectiveSetup = updateKeepsFeedback ? feedback.setup : null;
377
+ let setupStep;
378
+ if (!setupSkill) {
379
+ setupStep = step('SKIPPED', '项目未配置 releaseFinish.setupSkill。');
380
+ } else if (skipLocalHosts) {
381
+ setupStep = step('SKIPPED', '调用者已明确跳过宿主更新和加载,本轮不执行 setup。', { setupSkill });
382
+ } else if (candidateOwners.size > 1) {
383
+ setupStep = step('PENDING', `目标入口 ${setupSkill} 存在多个插件身份归属,不能用单个 setup 反馈完整收尾。`, {
384
+ setupSkill, candidates,
385
+ });
386
+ nextActions.push({ type: 'disambiguate-setup', setupSkill, projectRoot, candidates });
387
+ } else if (effectiveSetup) {
388
+ const status = effectiveSetup.outcome === 'completed' ? 'COMPLETE'
389
+ : effectiveSetup.outcome === 'failed' ? 'FAILED' : 'PENDING';
390
+ setupStep = step(status, effectiveSetup.summary, {
391
+ setupSkill,
392
+ result: effectiveSetup,
393
+ basis: 'agent-reported',
394
+ });
395
+ if (status === 'PENDING') nextActions.push({ type: 'invoke-setup', setupSkill, projectRoot, resume: effectiveSetup });
396
+ } else {
397
+ setupStep = step('PENDING', candidates.length > 0
398
+ ? `目标入口 ${setupSkill} 已有可用宿主,但未收到实际 setup 结果。`
399
+ : `目标入口 ${setupSkill} 尚缺可用的已加载宿主与技能元数据。`, { setupSkill, candidates });
400
+ if (candidates.length > 0) {
401
+ nextActions.push({
402
+ type: 'invoke-setup',
403
+ setupSkill,
404
+ projectRoot,
405
+ intent: 'read-only-diagnosis',
406
+ authorization: 'No new write authority is granted by this request.',
407
+ candidates: candidates.map((entry) => ({
408
+ unitId: entry.unitId,
409
+ host: entry.host,
410
+ plugin: entry.plugin,
411
+ version: entry.version,
412
+ skillFile: entry.skillFile,
413
+ })),
414
+ hostObservations: observations,
415
+ });
416
+ } else {
417
+ nextActions.push({
418
+ type: 'resolve-setup-target',
419
+ setupSkill,
420
+ projectRoot,
421
+ reason: hasTargets ? 'no-loaded-candidates' : 'no-host-targets',
422
+ targets: checklist.localHostUpdate.targets,
423
+ });
424
+ }
425
+ }
426
+
427
+ const sourceBranchStep = await inspectSourceBranch({ root: projectRoot, config: loaded.config, run });
428
+ const steps = {
429
+ merge: mergeStep,
430
+ 'host-update': hostUpdateStep,
431
+ 'host-load': hostLoadStep,
432
+ setup: setupStep,
433
+ 'source-branch': sourceBranchStep,
434
+ };
435
+ return {
436
+ command: 'post-release',
437
+ ...(update ? { operation: 'finish-with-local-host-update', localHostUpdate: update } : { checklist }),
438
+ finish: {
439
+ status: finishStatus(steps),
440
+ projectRoot,
441
+ planDigest: plan.digest,
442
+ configDigest: loaded.configDigest,
443
+ steps,
444
+ nextActions,
445
+ releaseStatusChanged: false,
446
+ },
447
+ };
448
+ }
@@ -52,10 +52,11 @@ const QODER_PLUGIN_LIST_ARGS = Object.freeze(['plugins', 'list', '--json']);
52
52
  const QODER_MARKETPLACE_LIST_ARGS = Object.freeze(['plugins', 'marketplace', 'list', '--json']);
53
53
  const QODER_PAYLOAD_CONTRACT = 'external-marketplace-v1';
54
54
 
55
- function attachFoundationFailure(error, { envelope, stdout }) {
55
+ function attachFoundationFailure(error, { envelope, stdout, stderr }) {
56
56
  Object.defineProperties(error, {
57
57
  foundationEnvelope: { value: envelope, enumerable: false },
58
58
  foundationStdout: { value: stdout, enumerable: false },
59
+ foundationStderr: { value: stderr, enumerable: false },
59
60
  });
60
61
  return error;
61
62
  }
@@ -220,6 +221,7 @@ function hubTargets(plan) {
220
221
  unitId: declaration.unitId,
221
222
  host,
222
223
  plugin: local.plugin,
224
+ version: unit?.targetVersion,
223
225
  hub,
224
226
  message: `${manualInstruction[host]} Install or upgrade ${local.plugin} from Hub ${hub.name}; release-skill does not execute or probe this action.`,
225
227
  ...(unit?.publicRepo ? { publicRepo: unit.publicRepo } : {}),
@@ -314,9 +316,23 @@ function buildShipNextStep({ root, statePath, unitIds }) {
314
316
  };
315
317
  }
316
318
 
319
+ function buildFinishCommand({ root, planPath, runPath }) {
320
+ if (![root, planPath, runPath].every((value) => typeof value === 'string' && value.length > 0)) return undefined;
321
+ return {
322
+ argv: [
323
+ 'release-skill', 'post-release',
324
+ '--root', root,
325
+ '--plan', planPath,
326
+ '--run', runPath,
327
+ '--finish',
328
+ ],
329
+ };
330
+ }
331
+
317
332
  export function derivePostReleaseChecklist(plan, {
318
333
  runPath,
319
334
  root,
335
+ planPath,
320
336
  statePath,
321
337
  unitIds,
322
338
  postVerifyComplete = false,
@@ -338,10 +354,12 @@ export function derivePostReleaseChecklist(plan, {
338
354
  const hasPendingPostVerify = postVerifyHooks(plan).length > 0 && !postVerifyComplete && targets.length > 0;
339
355
  const hasStatePath = typeof statePath === 'string' && statePath.length > 0;
340
356
  const selectedUnitIds = Array.isArray(unitIds) ? unitIds : undefined;
357
+ const finishCommand = hasPendingPostVerify ? undefined : buildFinishCommand({ root, planPath, runPath });
341
358
  return {
342
359
  command: 'post-release',
343
360
  status: 'AWAITING_USER_DECISION',
344
361
  planDigest: plan.digest,
362
+ ...(finishCommand ? { finishCommand } : {}),
345
363
  merge: {
346
364
  promptRequired: uncovered.length > 0,
347
365
  alreadyHandledByRelease: uncovered.length === 0,
@@ -592,12 +610,14 @@ async function defaultRun(command, args, options = {}) {
592
610
  watchdogReason: envelope.watchdogReason,
593
611
  ...(envelope.evidence?.spawnError ? { spawnError: envelope.evidence.spawnError } : {}),
594
612
  };
595
- throw attachFoundationFailure(error, { envelope, stdout });
613
+ throw attachFoundationFailure(error, { envelope, stdout, stderr });
596
614
  }
597
615
  return { stdout, stderr };
598
616
  }, { prefix: 'release-skill-host-command-' });
599
617
  }
600
618
 
619
+ export { defaultRun as runLocalFinishCommand };
620
+
601
621
  async function commandAvailable(command, host, run) {
602
622
  try {
603
623
  await run(command, ['--version'], { timeout: 10_000, env: hostEnvironment(host) });
@@ -839,6 +839,7 @@ export async function advanceShip(options = {}, injected = {}) {
839
839
  try {
840
840
  postRelease = derivePostReleaseChecklist(finalPlan, {
841
841
  root,
842
+ planPath: state.planPath,
842
843
  statePath,
843
844
  unitIds: state.selectedUnitIds,
844
845
  runPath: localFinishRunPath,