release-skill 0.9.12 → 0.9.14

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 (44) 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/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +45 -0
  6. package/INSTALL.md +2 -2
  7. package/INSTALL.zh-CN.md +2 -2
  8. package/README.md +39 -15
  9. package/README.zh-CN.md +29 -12
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +1141 -694
  12. package/adapters/claude/schemas/release-plan.schema.json +41 -1
  13. package/adapters/claude/schemas/release-project.schema.json +41 -0
  14. package/adapters/claude/skills/release-finish/SKILL.md +1 -1
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +1141 -694
  17. package/adapters/codex/schemas/release-plan.schema.json +41 -1
  18. package/adapters/codex/schemas/release-project.schema.json +41 -0
  19. package/adapters/codex/skills/release-finish/SKILL.md +1 -1
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1141 -694
  22. package/adapters/kimi/schemas/release-plan.schema.json +41 -1
  23. package/adapters/kimi/schemas/release-project.schema.json +41 -0
  24. package/adapters/kimi/skills/release-finish/SKILL.md +1 -1
  25. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  26. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1141 -694
  27. package/adapters/workbuddy/schemas/release-plan.schema.json +41 -1
  28. package/adapters/workbuddy/schemas/release-project.schema.json +41 -0
  29. package/adapters/workbuddy/skills/release-finish/SKILL.md +1 -1
  30. package/bin/release-skill-cli.mjs +26 -10
  31. package/bin/release-skill.bundle.mjs +1141 -694
  32. package/package.json +1 -1
  33. package/platform-manifest.json +4 -4
  34. package/references/02-project-config.md +10 -0
  35. package/references/05-evidence-and-errors.md +2 -1
  36. package/schemas/release-plan.schema.json +41 -1
  37. package/schemas/release-project.schema.json +41 -0
  38. package/skills/release-finish/SKILL.md +1 -1
  39. package/skills-src/release-finish/SKILL.md +1 -1
  40. package/src/commands/post-release-local.mjs +73 -3
  41. package/src/commands/prepare.mjs +56 -0
  42. package/src/core/postpublish.mjs +51 -0
  43. package/src/core/run-retention.mjs +227 -0
  44. package/src/core/run.mjs +10 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -2,11 +2,11 @@
2
2
  "schemaVersion": "1.0.0",
3
3
  "platformId": "release-skill",
4
4
  "platformName": "release-skill Plugin Platform",
5
- "version": "0.9.12",
5
+ "version": "0.9.14",
6
6
  "logicalRoot": "skills-src/",
7
- "logicalRootDigest": "aec96745b30289ed48d576fbfc98ecd3aacb834f83394be76d4734d7d0fc0ad4",
8
- "projectionDigest": "e5117d89c9f965e15a1464cc84f542cb1e75a685f842183d0cfc7a105ace5ceb",
9
- "projectionId": "release-skill:platform:0.9.12",
7
+ "logicalRootDigest": "8b50c2e36a5f17c2836ef874e5fcc8212691adfad1a09b649478cf6ffa13ea64",
8
+ "projectionDigest": "0ecf606f8fa746c5c7feab61556325ba9951d68cbe27d12db145abd4b1f6924b",
9
+ "projectionId": "release-skill:platform:0.9.14",
10
10
  "manifestRequired": true,
11
11
  "projectionSources": {
12
12
  "skills-src-root": {
@@ -188,8 +188,18 @@ releaseUnits:
188
188
  - id: notify-downstream
189
189
  command: [node, scripts/notify-downstream.mjs]
190
190
  phase: postVerify
191
+ localHostUpdate:
192
+ plugin: postpublish-example
193
+ hosts: [claude, codex, kimi, codebuddy, workbuddy]
194
+ hub:
195
+ name: example-hub
196
+ githubHost: github.com
197
+ repo: example/example-hub
198
+ ref: refs/heads/main
191
199
  ```
192
200
 
201
+ `localHostUpdate` 只声明 `postVerify` 完成后的本机更新候选。`plugin` 必须与冻结公开快照中的插件 manifest 身份一致,`hosts` 只能使用现有的五个 local-finish 宿主 ID,`hub.repo` 使用 `owner/repo`,`hub.ref` 必须是完整的 `refs/heads/...` 引用。声明该字段时,同一 `postPublish` 块至少要有一个 `phase: postVerify` Hook。prepare 只校验冻结声明和插件身份,不访问 Hub,也不要求 Hub 预先存在目标版本。
202
+
193
203
  ---
194
204
 
195
205
  ## 3. 公开源码镜像与分发包
@@ -207,7 +207,8 @@ v2 的顶层结构封闭(`additionalProperties: false`),只接受 Schema
207
207
 
208
208
  ### 5.1 文件保留
209
209
 
210
- - 证据目录在执行完成后不得被自动删除。
210
+ - 不得手工删除运行证据。下一次生产 prepare 会在现有项目锁内执行一次尽力清理,只删除已被更新完整谱系取代的终态谱系,以及没有有效计划或外部写入迹象的封口失败 prepare。
211
+ - 当前 prepare、当前计划对应的全部 run、最近完整谱系、任何非终态或部分成功谱系,以及损坏、越界、身份含糊或信息冲突的目录都受保护。清理以 `.release-skill/runs/` 下的真实直接子目录为单位,不删除计划、批准或 ship 记录;单个删除失败只记录目录基名和错误码,不阻断 prepare。
211
212
  - `evidence.jsonl` 为追加写入,不得在执行过程中被截断。
212
213
  - `summary.json` 在执行结束时原子写入(writer 单次封口:`finish()` 只能成功一次,封口后 append/finish 均为 no-op;正常命令的成功与失败路径都必须封口)。
213
214
  - `states/` 台账为不可变写集:外部检查点成功后立即记账,失败时保留 `PARTIAL` 状态,reconcile 据此跳过已成功步骤。
@@ -1465,6 +1465,9 @@
1465
1465
  "const": "tag-worktree",
1466
1466
  "description": "时序契约:载荷只能来自 tagCommit 处的 detached worktree,绝不读取工作区状态"
1467
1467
  },
1468
+ "localHostUpdate": {
1469
+ "$ref": "#/definitions/planLocalHostUpdate"
1470
+ },
1468
1471
  "executionBundle": {
1469
1472
  "type": "object",
1470
1473
  "description": "冻结的私有执行包(F-01/T1):closure 直接采用 Foundation computeResourceClosure() 的返回(排序、去重、受收容读取与摘要均由 Foundation 完成),publicFiles 是从发布单元投影的公开映射。计划是该执行包的唯一事实源:随整个 plan digest 绑定,不维护平行 manifest,也不另造 bundle digest。字节写入计划权威 .release-skill 的 postpublish-bundles/<closure.digest>/<resource.path>;distribute/postVerify 在任何 hook/外部写之前经 Foundation 严格复验并重算闭包,任何不一致即失败。",
@@ -1598,7 +1601,44 @@
1598
1601
  }
1599
1602
  }
1600
1603
  }
1601
- ]
1604
+ ],
1605
+ "allOf": [{
1606
+ "if": { "required": ["localHostUpdate"] },
1607
+ "then": {
1608
+ "required": ["hooks"],
1609
+ "properties": {
1610
+ "hooks": {
1611
+ "contains": { "type": "object", "required": ["phase"], "properties": { "phase": { "const": "postVerify" } } }
1612
+ }
1613
+ }
1614
+ }
1615
+ }]
1616
+ },
1617
+ "planLocalHostUpdate": {
1618
+ "type": "object",
1619
+ "additionalProperties": false,
1620
+ "required": ["plugin", "hosts", "hub"],
1621
+ "properties": {
1622
+ "plugin": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" },
1623
+ "hosts": {
1624
+ "type": "array",
1625
+ "minItems": 1,
1626
+ "uniqueItems": true,
1627
+ "items": { "type": "string", "enum": ["claude", "codex", "kimi", "codebuddy", "workbuddy"] }
1628
+ },
1629
+ "hub": { "$ref": "#/definitions/planLocalHostHub" }
1630
+ }
1631
+ },
1632
+ "planLocalHostHub": {
1633
+ "type": "object",
1634
+ "additionalProperties": false,
1635
+ "required": ["name", "repo", "ref"],
1636
+ "properties": {
1637
+ "name": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" },
1638
+ "githubHost": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$" },
1639
+ "repo": { "type": "string", "pattern": "^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$" },
1640
+ "ref": { "type": "string", "pattern": "^refs\\/heads\\/[A-Za-z0-9][A-Za-z0-9._-]*(?:\\/[A-Za-z0-9][A-Za-z0-9._-]*)*$" }
1641
+ }
1602
1642
  },
1603
1643
  "planPostPublishHook": {
1604
1644
  "type": "object",
@@ -352,6 +352,7 @@
352
352
  "then": {
353
353
  "required": [
354
354
  "plugin",
355
+ "marketplace",
355
356
  "entrySkill"
356
357
  ]
357
358
  }
@@ -1007,7 +1008,47 @@
1007
1008
  "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
1008
1009
  },
1009
1010
  "uniqueItems": true
1011
+ },
1012
+ "localHostUpdate": {
1013
+ "$ref": "#/definitions/localHostUpdate"
1014
+ }
1015
+ },
1016
+ "allOf": [{
1017
+ "if": { "required": ["localHostUpdate"] },
1018
+ "then": {
1019
+ "required": ["hooks"],
1020
+ "properties": {
1021
+ "hooks": {
1022
+ "contains": { "type": "object", "required": ["phase"], "properties": { "phase": { "const": "postVerify" } } }
1023
+ }
1024
+ }
1010
1025
  }
1026
+ }]
1027
+ },
1028
+ "localHostUpdate": {
1029
+ "type": "object",
1030
+ "additionalProperties": false,
1031
+ "required": ["plugin", "hosts", "hub"],
1032
+ "properties": {
1033
+ "plugin": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" },
1034
+ "hosts": {
1035
+ "type": "array",
1036
+ "minItems": 1,
1037
+ "uniqueItems": true,
1038
+ "items": { "type": "string", "enum": ["claude", "codex", "kimi", "codebuddy", "workbuddy"] }
1039
+ },
1040
+ "hub": { "$ref": "#/definitions/localHostHub" }
1041
+ }
1042
+ },
1043
+ "localHostHub": {
1044
+ "type": "object",
1045
+ "additionalProperties": false,
1046
+ "required": ["name", "repo", "ref"],
1047
+ "properties": {
1048
+ "name": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" },
1049
+ "githubHost": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$" },
1050
+ "repo": { "type": "string", "pattern": "^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$" },
1051
+ "ref": { "type": "string", "pattern": "^refs\\/heads\\/[A-Za-z0-9][A-Za-z0-9._-]*(?:\\/[A-Za-z0-9][A-Za-z0-9._-]*)*$" }
1011
1052
  }
1012
1053
  },
1013
1054
  "postPublishHook": {
@@ -35,7 +35,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill-local-finish.mjs" \
35
35
 
36
36
  1. `merge.promptRequired=false` 时,发布工作流已经推进或初始化目标分支,不再询问合并。
37
37
  2. `merge.promptRequired=true` 时,向用户说明尚未覆盖的发布分支,并询问是否需要合并。用户同意后,先只读核对源分支、目标分支、工作区状态和项目既有合并方式,再用明确的分支名执行;本脚本不猜分支,也不自动推送。
38
- 3. `localHostUpdate.promptRequired=true` 时,列出计划覆盖的宿主,询问是否更新本机插件。两个问题可以一次问完。
38
+ 3. `localHostUpdate.promptRequired=true` 时,列出计划覆盖的宿主,询问是否更新本机插件。Hub-backed 目标必须显示其声明的 Hub、插件和宿主,并明确这是人工安装/升级入口,不运行本机命令;Claude/Codex 使用现有 marketplace 管理入口,Kimi 使用冻结 GitHub Release 和现有人工确认路径,CodeBuddy/WorkBuddy 明确人工处理且不能固定 Hub ref。后续“用户同意更新”段仅适用于 `available=true` 的 executable externalActions 目标;Hub-backed 始终人工入口。两个问题可以一次问完。
39
39
 
40
40
  ## postVerify 提案送达边界
41
41
 
@@ -35,7 +35,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill-local-finish.mjs" \
35
35
 
36
36
  1. `merge.promptRequired=false` 时,发布工作流已经推进或初始化目标分支,不再询问合并。
37
37
  2. `merge.promptRequired=true` 时,向用户说明尚未覆盖的发布分支,并询问是否需要合并。用户同意后,先只读核对源分支、目标分支、工作区状态和项目既有合并方式,再用明确的分支名执行;本脚本不猜分支,也不自动推送。
38
- 3. `localHostUpdate.promptRequired=true` 时,列出计划覆盖的宿主,询问是否更新本机插件。两个问题可以一次问完。
38
+ 3. `localHostUpdate.promptRequired=true` 时,列出计划覆盖的宿主,询问是否更新本机插件。Hub-backed 目标必须显示其声明的 Hub、插件和宿主,并明确这是人工安装/升级入口,不运行本机命令;Claude/Codex 使用现有 marketplace 管理入口,Kimi 使用冻结 GitHub Release 和现有人工确认路径,CodeBuddy/WorkBuddy 明确人工处理且不能固定 Hub ref。后续“用户同意更新”段仅适用于 `available=true` 的 executable externalActions 目标;Hub-backed 始终人工入口。两个问题可以一次问完。
39
39
 
40
40
  ## postVerify 提案送达边界
41
41
 
@@ -157,6 +157,74 @@ function pluginTargets(plan) {
157
157
  ));
158
158
  }
159
159
 
160
+ function hubTargets(plan) {
161
+ if (plan?.planVersion === undefined) return [];
162
+ return normalizePostPublishView(plan).flatMap((declaration) => {
163
+ const local = declaration.localHostUpdate;
164
+ if (!local) return [];
165
+ const hub = { ...local.hub, githubHost: local.hub.githubHost ?? 'github.com' };
166
+ const unit = (plan.units ?? []).find((candidate) => candidate.id === declaration.unitId);
167
+ const tag = (plan.externalActions ?? []).find((action) => action.type === 'create-tag' && action.unitId === declaration.unitId)?.parameters?.tag;
168
+ const manualInstruction = {
169
+ claude: 'Use Claude’s existing marketplace management entry.',
170
+ codex: 'Use Codex’s existing marketplace management entry.',
171
+ kimi: `Use the frozen GitHub Release${tag ? ` (${tag})` : ''} and the existing manual confirmation path; Kimi has no marketplace index.`,
172
+ codebuddy: 'Handle manually; CodeBuddy cannot pin a Hub ref in this flow.',
173
+ workbuddy: 'Handle manually; WorkBuddy cannot pin a Hub ref in this flow (it follows the CodeBuddy manual boundary).',
174
+ };
175
+ return local.hosts.map((host) => ({
176
+ targetKind: 'hub-backed',
177
+ executionMode: 'manual',
178
+ unitId: declaration.unitId,
179
+ host,
180
+ plugin: local.plugin,
181
+ hub,
182
+ message: `${manualInstruction[host]} Install or upgrade ${local.plugin} from Hub ${hub.name}; release-skill does not execute or probe this action.`,
183
+ ...(unit?.publicRepo ? { publicRepo: unit.publicRepo } : {}),
184
+ ...(tag ? { frozenTag: tag } : {}),
185
+ }));
186
+ });
187
+ }
188
+
189
+ function mergePostReleaseTargets(executableTargets, manualTargets) {
190
+ const byIdentity = new Map();
191
+ for (const target of [...executableTargets, ...manualTargets]) {
192
+ const key = `${target.unitId}\u0000${target.host}\u0000${target.plugin}`;
193
+ const existing = byIdentity.get(key);
194
+ if (existing && existing.targetKind === 'hub-backed' && target.targetKind === 'hub-backed') {
195
+ const sameHub = ['name', 'githubHost', 'repo', 'ref'].every((field) => existing.hub[field] === target.hub[field]);
196
+ if (!sameHub) throw new Error(`post-release target identity conflict for ${target.unitId}/${target.host}/${target.plugin}`);
197
+ continue;
198
+ }
199
+ const hubMatchesExecutable = (hub, executable) => executable.marketplace === hub.name
200
+ && executable.marketplaceRepo === hub.repo
201
+ && executable.marketplaceRef === hub.ref
202
+ && executable.githubHost === hub.githubHost;
203
+ // When the same frozen plugin is available through both sources, the
204
+ // executable action wins only when both frozen source identities agree.
205
+ if (existing && existing.targetKind !== 'hub-backed' && target.targetKind === 'hub-backed') {
206
+ if (!hubMatchesExecutable(target.hub, existing)) {
207
+ throw new Error(`post-release target identity conflict for ${target.unitId}/${target.host}/${target.plugin}`);
208
+ }
209
+ continue;
210
+ }
211
+ if (existing && existing.targetKind === 'hub-backed' && target.targetKind !== 'hub-backed') {
212
+ if (!hubMatchesExecutable(existing.hub, target)) {
213
+ throw new Error(`post-release target identity conflict for ${target.unitId}/${target.host}/${target.plugin}`);
214
+ }
215
+ byIdentity.set(key, target);
216
+ continue;
217
+ }
218
+ if (existing && existing.actionId !== target.actionId) {
219
+ throw new Error(`post-release target identity conflict for ${target.unitId}/${target.host}/${target.plugin}`);
220
+ }
221
+ if (!existing) byIdentity.set(key, target);
222
+ }
223
+ return [...byIdentity.values()].sort((left, right) => (
224
+ left.host.localeCompare(right.host) || left.unitId.localeCompare(right.unitId) || left.plugin.localeCompare(right.plugin)
225
+ ));
226
+ }
227
+
160
228
  function assertExecutableTarget(target) {
161
229
  for (const field of [
162
230
  'actionId', 'unitId', 'plugin', 'version',
@@ -200,7 +268,9 @@ export function derivePostReleaseChecklist(plan, {
200
268
  const uncovered = units.filter((unit) => (
201
269
  !BRANCH_ACTION_INCLUDED.has(unit.productionConfig?.branchStrategy)
202
270
  ));
203
- const targets = pluginTargets(plan);
271
+ const executableTargets = pluginTargets(plan);
272
+ const manualTargets = hubTargets(plan);
273
+ const targets = mergePostReleaseTargets(executableTargets, manualTargets);
204
274
  const hasPendingPostVerify = postVerifyHooks(plan).length > 0 && !postVerifyComplete && targets.length > 0;
205
275
  const hasStatePath = typeof statePath === 'string' && statePath.length > 0;
206
276
  const selectedUnitIds = Array.isArray(unitIds) ? unitIds : undefined;
@@ -220,7 +290,7 @@ export function derivePostReleaseChecklist(plan, {
220
290
  },
221
291
  localHostUpdate: {
222
292
  promptRequired: targets.length > 0 && !hasPendingPostVerify,
223
- available: targets.length > 0 && !hasPendingPostVerify,
293
+ available: executableTargets.length > 0 && !hasPendingPostVerify,
224
294
  ...(!hasPendingPostVerify && runPath ? { runPath } : {}),
225
295
  ...(hasPendingPostVerify ? {
226
296
  ...(hasStatePath ? { nextSteps: [buildShipNextStep({ root, statePath, unitIds: selectedUnitIds })] } : {}),
@@ -1359,7 +1429,7 @@ async function updateLocalHostPluginsInternal({
1359
1429
  const selected = new Set(Array.isArray(selectedHosts) ? selectedHosts : []);
1360
1430
  const unknown = [...selected].filter((host) => !checklist.localHostUpdate.hosts.includes(host));
1361
1431
  if (unknown.length > 0) throw new Error(`selected hosts are not declared by the plan: ${unknown.join(', ')}`);
1362
- const targets = checklist.localHostUpdate.targets.filter((item) => selected.has(item.host));
1432
+ const targets = pluginTargets(plan).filter((item) => selected.has(item.host));
1363
1433
  for (const target of targets) assertExecutableTarget(target);
1364
1434
 
1365
1435
  const results = [];
@@ -77,6 +77,7 @@ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
77
77
  import { observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
78
78
  import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
79
79
  import { createProductionPrepareRunDir } from '../core/run.mjs';
80
+ import { cleanupRunRetention } from '../core/run-retention.mjs';
80
81
  import {
81
82
  PLATFORMS,
82
83
  normalizeHostId,
@@ -895,6 +896,32 @@ async function processSnapshots(config, root, evidence, runDir, production = fal
895
896
  return { unitResults, snapshotDigests };
896
897
  }
897
898
 
899
+ /** Validate Hub-backed plugin identities against the already frozen snapshot. */
900
+ async function validateFrozenLocalHostUpdatePlugins(unitResults) {
901
+ for (const { unit, manifest } of unitResults) {
902
+ const declaration = unit.postPublish?.localHostUpdate;
903
+ if (!declaration) continue;
904
+ const manifestEntries = (manifest.entries ?? [])
905
+ .filter((entry) => /(?:^|\/)\.(?:claude|codex|kimi|codebuddy)-plugin\/plugin\.json$/u.test(entry.path));
906
+ const identities = new Set();
907
+ for (const entry of manifestEntries) {
908
+ try {
909
+ const parsed = JSON.parse(await readFile(resolve(manifest.outputDir, entry.path), 'utf8'));
910
+ if (typeof parsed?.name === 'string' && parsed.name.trim().length > 0) identities.add(parsed.name);
911
+ } catch (cause) {
912
+ throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" localHostUpdate plugin manifest is unreadable`, {
913
+ unitId: unit.id, path: entry.path, cause: cause.code ?? cause.message,
914
+ });
915
+ }
916
+ }
917
+ if (identities.size === 0 || identities.size !== 1 || !identities.has(declaration.plugin)) {
918
+ throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" localHostUpdate.plugin "${declaration.plugin}" does not match a frozen public plugin manifest`, {
919
+ unitId: unit.id, plugin: declaration.plugin, frozenPlugins: [...identities].sort(),
920
+ });
921
+ }
922
+ }
923
+ }
924
+
898
925
  function resolveProductionBranch(unit, version) {
899
926
  const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
900
927
  const tag = tagTemplate.replace('{version}', version);
@@ -3326,6 +3353,34 @@ export async function prepareRelease(options) {
3326
3353
  const evidence = createEvidenceWriter({ runDir, command: 'prepare', clock });
3327
3354
 
3328
3355
  try {
3356
+ // R14-01: production prepare performs one best-effort retention pass only
3357
+ // after the current run/evidence authority exists and before high-cost
3358
+ // config, snapshot, and hook work begins. It never blocks prepare.
3359
+ if (production) {
3360
+ try {
3361
+ const retention = await (options.runRetentionFn ?? cleanupRunRetention)({
3362
+ releaseDir,
3363
+ currentRunDir: runDir,
3364
+ });
3365
+ await evidence.append({
3366
+ phase: 'run-retention',
3367
+ status: 'completed',
3368
+ scanned: retention.scanned,
3369
+ deleted: retention.deleted,
3370
+ errors: retention.errors,
3371
+ });
3372
+ } catch (error) {
3373
+ await evidence.append({
3374
+ phase: 'run-retention',
3375
+ status: 'warning',
3376
+ scanned: 0,
3377
+ deleted: 0,
3378
+ errors: 1,
3379
+ error: { code: error?.code ?? 'RUN_RETENTION_FAILED', message: error?.message ?? String(error) },
3380
+ });
3381
+ }
3382
+ }
3383
+
3329
3384
  // --- Step 1: Load and validate config ---
3330
3385
  await evidence.append({ phase: 'config', status: 'started' });
3331
3386
 
@@ -4212,6 +4267,7 @@ export async function prepareRelease(options) {
4212
4267
  const { unitResults, snapshotDigests } = await processSnapshots(
4213
4268
  selectedConfig, realRoot, evidence, runDir, production,
4214
4269
  );
4270
+ await validateFrozenLocalHostUpdatePlugins(unitResults);
4215
4271
 
4216
4272
  // Snapshot gates always run on disposable writable copies. The public
4217
4273
  // snapshot authority is re-digested after every gate and is never exposed
@@ -42,6 +42,9 @@ const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
42
42
 
43
43
  /** Branch pattern (leading alphanumeric blocks option-like names). */
44
44
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
45
+ const LOCAL_HOSTS = new Set(['claude', 'codex', 'kimi', 'codebuddy', 'workbuddy']);
46
+ const HUB_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/u;
47
+ const HUB_REF_RE = /^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/u;
45
48
 
46
49
  /** Frozen payload source binding: payload only ever comes from the tag worktree. */
47
50
  export const PAYLOAD_SOURCE_TAG_WORKTREE = 'tag-worktree';
@@ -478,6 +481,54 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
478
481
  fail(`${unitLabel}assertMainVersionAhead must be a boolean`);
479
482
  }
480
483
 
484
+ const localHostUpdate = postPublish.localHostUpdate;
485
+ if (localHostUpdate !== undefined) {
486
+ if (!localHostUpdate || typeof localHostUpdate !== 'object' || Array.isArray(localHostUpdate)) {
487
+ fail(`${unitLabel}localHostUpdate must be a non-null object`);
488
+ }
489
+ if (typeof localHostUpdate.plugin !== 'string' || localHostUpdate.plugin.trim().length === 0) {
490
+ fail(`${unitLabel}localHostUpdate.plugin must be a non-empty string`);
491
+ }
492
+ if (!Array.isArray(localHostUpdate.hosts) || localHostUpdate.hosts.length === 0) {
493
+ fail(`${unitLabel}localHostUpdate.hosts must be a non-empty array`);
494
+ }
495
+ const hosts = new Set();
496
+ for (const host of localHostUpdate.hosts) {
497
+ if (typeof host !== 'string' || !LOCAL_HOSTS.has(host)) {
498
+ fail(`${unitLabel}localHostUpdate.hosts contains unsupported host ${JSON.stringify(host)}`);
499
+ }
500
+ if (hosts.has(host)) fail(`${unitLabel}localHostUpdate.hosts contains duplicate host "${host}"`);
501
+ hosts.add(host);
502
+ }
503
+ const hub = localHostUpdate.hub;
504
+ if (!hub || typeof hub !== 'object' || Array.isArray(hub)) {
505
+ fail(`${unitLabel}localHostUpdate.hub must be a non-null object`);
506
+ }
507
+ for (const field of ['name', 'repo', 'ref']) {
508
+ if (typeof hub[field] !== 'string' || hub[field].trim().length === 0) {
509
+ fail(`${unitLabel}localHostUpdate.hub.${field} must be a non-empty string`);
510
+ }
511
+ }
512
+ if (hub.githubHost !== undefined && (typeof hub.githubHost !== 'string' || !HUB_HOST_RE.test(hub.githubHost))) {
513
+ fail(`${unitLabel}localHostUpdate.hub.githubHost must be a non-empty string when provided`);
514
+ }
515
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(hub.repo)) {
516
+ fail(`${unitLabel}localHostUpdate.hub.repo must be owner/repo`);
517
+ }
518
+ if (!HUB_REF_RE.test(hub.ref)) {
519
+ fail(`${unitLabel}localHostUpdate.hub.ref must be a refs/heads/... reference`);
520
+ }
521
+ if (!(postPublish.hooks ?? []).some((hook) => hook.phase === 'postVerify')) {
522
+ fail(`${unitLabel}localHostUpdate requires at least one postVerify hook`);
523
+ }
524
+ if (typeof options.expectedPlugin === 'string' && localHostUpdate.plugin !== options.expectedPlugin) {
525
+ fail(`${unitLabel}localHostUpdate.plugin must match the frozen public plugin manifest`, {
526
+ expectedPlugin: options.expectedPlugin,
527
+ plugin: localHostUpdate.plugin,
528
+ });
529
+ }
530
+ }
531
+
481
532
  return postPublish;
482
533
  }
483
534