release-skill 0.9.7 → 0.9.9

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 (34) hide show
  1. package/.agents/plugins/marketplace.json +9 -0
  2. package/.claude-plugin/marketplace.json +11 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codebuddy-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +2 -2
  6. package/.kimi-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +52 -0
  8. package/INSTALL.md +17 -9
  9. package/INSTALL.zh-CN.md +15 -9
  10. package/README.md +31 -28
  11. package/README.zh-CN.md +26 -26
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +298 -45
  15. package/adapters/claude/skills/release-finish/SKILL.md +12 -4
  16. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  17. package/adapters/codex/bin/release-skill.bundle.mjs +298 -45
  18. package/adapters/codex/skills/release-finish/SKILL.md +12 -4
  19. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  20. package/adapters/kimi/bin/release-skill.bundle.mjs +298 -45
  21. package/adapters/kimi/skills/release-finish/SKILL.md +12 -4
  22. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  23. package/adapters/workbuddy/bin/release-skill.bundle.mjs +298 -45
  24. package/adapters/workbuddy/skills/release-finish/SKILL.md +12 -4
  25. package/bin/release-skill.bundle.mjs +298 -45
  26. package/package.json +1 -1
  27. package/platform-manifest.json +4 -4
  28. package/skills/release-finish/SKILL.md +12 -4
  29. package/skills-src/release-finish/SKILL.md +12 -4
  30. package/src/commands/post-release-local.mjs +292 -39
  31. package/src/commands/ship.mjs +4 -6
  32. package/src/commands/verify.mjs +8 -8
  33. package/src/core/postpublish.mjs +24 -0
  34. package/src/core/recovery.mjs +8 -6
@@ -35,7 +35,7 @@ const SAFE_ENV_KEYS = Object.freeze([
35
35
  'PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR',
36
36
  'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
37
37
  'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
38
- 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'KIMI_CONFIG_DIR',
38
+ 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'KIMI_CODE_HOME',
39
39
  'CODEBUDDY_CONFIG_DIR', 'WORKBUDDY_CONFIG_DIR',
40
40
  ]);
41
41
  const CODEBUDDY_PLUGIN_LIST_ARGS = Object.freeze(['plugin', 'list', '--json']);
@@ -422,7 +422,7 @@ function hostEnvironment(host, { kimiHome } = {}) {
422
422
  env.CODEBUDDY_CONFIG_DIR = join(env.HOME, '.workbuddy');
423
423
  env.WORKBUDDY_CONFIG_DIR = join(env.HOME, '.workbuddy');
424
424
  }
425
- if (host === 'kimi' && kimiHome) env.KIMI_CONFIG_DIR = kimiHome;
425
+ if (host === 'kimi' && kimiHome) env.KIMI_CODE_HOME = kimiHome;
426
426
  return env;
427
427
  }
428
428
 
@@ -647,6 +647,67 @@ function normalizeGitSource(source) {
647
647
  .replace(/\/$/u, '');
648
648
  }
649
649
 
650
+ function parseFrozenRemoteRef(target, stdout) {
651
+ const directRefs = new Map();
652
+ const peeledRefs = new Map();
653
+ const expected = target.marketplaceRef;
654
+ const allowedDirect = expected.startsWith('refs/')
655
+ ? new Set([expected])
656
+ : new Set([`refs/heads/${expected}`, `refs/tags/${expected}`]);
657
+ for (const line of String(stdout ?? '').split(/\r?\n/u)) {
658
+ if (line.length === 0) continue;
659
+ const match = /^([a-f0-9]{40})\t([^\s]+)$/u.exec(line);
660
+ if (!match) throw new Error('git ls-remote returned an invalid line');
661
+ const [, commit, remoteRef] = match;
662
+ const peeled = remoteRef.endsWith('^{}');
663
+ const direct = peeled ? remoteRef.slice(0, -3) : remoteRef;
664
+ if (!allowedDirect.has(direct)) {
665
+ throw new Error(`git ls-remote returned an unexpected ref ${remoteRef}`);
666
+ }
667
+ const destination = peeled ? peeledRefs : directRefs;
668
+ const previous = destination.get(direct);
669
+ if (previous && previous !== commit) {
670
+ throw new Error(`git ls-remote returned conflicting values for ${remoteRef}`);
671
+ }
672
+ destination.set(direct, commit);
673
+ }
674
+ const resolved = [...allowedDirect]
675
+ .filter((remoteRef) => directRefs.has(remoteRef) || peeledRefs.has(remoteRef))
676
+ .map((remoteRef) => peeledRefs.get(remoteRef) ?? directRefs.get(remoteRef));
677
+ return {
678
+ found: resolved.length > 0,
679
+ exact: resolved.length > 0 && resolved.every((commit) => commit === target.marketplaceCommit),
680
+ };
681
+ }
682
+
683
+ async function preflightStructuredMarketplace(target, env, run) {
684
+ try {
685
+ const observed = await run('git', [
686
+ 'ls-remote', '--exit-code', `https://${target.githubHost}/${target.marketplaceRepo}.git`,
687
+ target.marketplaceRef, `${target.marketplaceRef}^{}`,
688
+ ], { env, timeout: 30_000 });
689
+ const remote = parseFrozenRemoteRef(target, observed.stdout);
690
+ if (!remote.found) {
691
+ return {
692
+ ok: false,
693
+ reason: `${target.host} frozen marketplace ref ${target.marketplaceRef} is missing`,
694
+ };
695
+ }
696
+ if (!remote.exact) {
697
+ return {
698
+ ok: false,
699
+ reason: `${target.host} frozen marketplace ref ${target.marketplaceRef} does not resolve to ${target.marketplaceCommit}`,
700
+ };
701
+ }
702
+ return { ok: true };
703
+ } catch (error) {
704
+ return {
705
+ ok: false,
706
+ reason: `${target.host} could not prove the frozen marketplace ref: ${error?.message ?? String(error)}`,
707
+ };
708
+ }
709
+ }
710
+
650
711
  async function observeMarketplace(target, command, env, run) {
651
712
  const listed = await run(command, ['plugin', 'marketplace', 'list', '--json'], { env });
652
713
  const parsed = parseJson(listed.stdout, `${target.host} marketplace list`);
@@ -835,6 +896,11 @@ async function runStructuredUpdate(target, detected, run, {
835
896
  return { status: 'ALREADY_CURRENT', version: target.version };
836
897
  }
837
898
 
899
+ if (!before.marketplace.exact) {
900
+ const preflight = await preflightStructuredMarketplace(target, env, run);
901
+ if (!preflight.ok) return { status: 'MANUAL_REQUIRED', reason: preflight.reason };
902
+ }
903
+
838
904
  const marketplaceRebound = await bindStructuredMarketplace(target, command, env, run, before);
839
905
  const current = target.host === 'claude' && marketplaceRebound
840
906
  ? await observeStructuredTarget(target, command, env, run)
@@ -898,49 +964,223 @@ function parseCodeBuddyRemoteObservation(target, stdout) {
898
964
 
899
965
  function kimiExpectProgram({ removePlugin } = {}) {
900
966
  const removeCommand = removePlugin
901
- ? `send -- "/plugins remove $removePlugin\\r"
967
+ ? `submitCommand "/plugins remove $removePlugin"
902
968
  expect {
903
969
  -nocase -re {(remove|delete|uninstall).*(confirm|sure)|(confirm|sure).*(remove|delete|uninstall)} {
904
970
  expect {
905
- -ex $removePlugin { send -- "\\033\\[B\\r" }
906
- timeout { exit 95 }
907
- eof { exit 96 }
971
+ -ex $removePlugin {
972
+ send -- "\\033\\[B"
973
+ send -- "\\033\\[13u"
974
+ }
975
+ timeout { failTimeout remove-confirmation 125 127 }
976
+ eof { failEof remove-confirmation 126 }
977
+ }
978
+ expect {
979
+ -nocase -re {Trust this folder\\?} { directoryTrust }
980
+ -re $promptPattern {}
981
+ timeout { failTimeout remove-prompt 128 130 }
982
+ eof { failEof remove-prompt 129 }
908
983
  }
909
- expect -re $promptPattern
910
984
  }
911
985
  -re $promptPattern {}
912
- timeout { exit 93 }
913
- eof { exit 94 }
986
+ -nocase -re {Trust this folder\\?} { directoryTrust }
987
+ timeout { failTimeout remove-dialog 122 124 }
988
+ eof { failEof remove-dialog 123 }
914
989
  }
915
990
  `
916
991
  : '';
917
- return `set timeout 180
918
- foreach variable {RELEASE_SKILL_KIMI_COMMAND RELEASE_SKILL_KIMI_INSTALL_URL RELEASE_SKILL_KIMI_REMOVE_PLUGIN} {
992
+ return `set timeout 240
993
+ foreach variable {RELEASE_SKILL_KIMI_COMMAND RELEASE_SKILL_KIMI_INSTALL_URL RELEASE_SKILL_KIMI_REMOVE_PLUGIN RELEASE_SKILL_KIMI_EXPECTED_REPO RELEASE_SKILL_KIMI_EXPECTED_TAG} {
919
994
  if {![info exists env($variable)]} { exit 90 }
920
995
  }
921
996
  set kimiCommand $env(RELEASE_SKILL_KIMI_COMMAND)
922
997
  set installUrl $env(RELEASE_SKILL_KIMI_INSTALL_URL)
923
998
  set removePlugin $env(RELEASE_SKILL_KIMI_REMOVE_PLUGIN)
924
- set promptPattern {(?:(?:^|\\r|\\n)> |(?:^|\\r|\\n)(?:\\033\\[[0-9;?]*[ -/]*[@-~])*│[ \\t]*>[ \\t]*│(?:\\033\\[[0-9;?]*[ -/]*[@-~])*)(?![^\\n]|\\n)}
999
+ set expectedRepo $env(RELEASE_SKILL_KIMI_EXPECTED_REPO)
1000
+ set expectedTag $env(RELEASE_SKILL_KIMI_EXPECTED_TAG)
1001
+ set promptPattern {(?:(?:^|\\r|\\n)> (?:\\r*\\n|$)|(?:^|\\r|\\n)(?:(?:\\033\\[[0-9;?]*[ -/]*[@-~])|\\033\\][^\\x07]*\\x07|[ \\t])*│[^\\r\\n]*>[^\\r\\n]*│(?:(?:\\033\\[[0-9;?]*[ -/]*[@-~])|\\033\\][^\\x07]*\\x07|[ \\t])*(?:\\r*\\n|$))}
1002
+
1003
+ proc cleanScreen {value} {
1004
+ regsub -all {\\033\\[[0-9;?]*[ -/]*[@-~]} $value {} value
1005
+ regsub -all {\\033\\][^\\x07]*\\x07} $value {} value
1006
+ regsub -all {\\r} $value {} value
1007
+ return $value
1008
+ }
1009
+
1010
+ proc compactScreen {value} {
1011
+ set value [cleanScreen $value]
1012
+ regsub -all {[[:space:]]+} $value {} value
1013
+ return $value
1014
+ }
1015
+
1016
+ proc extractPluginTrustDialog {value} {
1017
+ set cleaned [cleanScreen $value]
1018
+ set lowered [string tolower $cleaned]
1019
+ set dialogStart -1
1020
+ foreach marker {"install third-party plugin " "trust and install from "} {
1021
+ set markerStart [string last $marker $lowered]
1022
+ if {$markerStart > $dialogStart} { set dialogStart $markerStart }
1023
+ }
1024
+ if {$dialogStart < 0} { return "" }
1025
+ return [string range $cleaned $dialogStart end]
1026
+ }
1027
+
1028
+ proc extractPluginTrustIdentity {value} {
1029
+ set cleaned [cleanScreen $value]
1030
+ set lowered [string tolower $cleaned]
1031
+ set identityStart -1
1032
+ foreach marker {"install third-party plugin " "trust and install from "} {
1033
+ set markerStart [string last $marker $lowered]
1034
+ if {$markerStart >= 0 && $markerStart + [string length $marker] > $identityStart} {
1035
+ set identityStart [expr {$markerStart + [string length $marker]}]
1036
+ }
1037
+ }
1038
+ if {$identityStart < 0} { return "" }
1039
+ set identityEnd [string first "?" $cleaned $identityStart]
1040
+ if {$identityEnd < 0} { return "" }
1041
+ return [string range $cleaned $identityStart [expr {$identityEnd - 1}]]
1042
+ }
1043
+
1044
+ proc failTimeout {state timeoutCode unknownCode} {
1045
+ global expect_out
1046
+ set buffer ""
1047
+ if {[info exists expect_out(buffer)]} { set buffer [string trim [cleanScreen $expect_out(buffer)]] }
1048
+ if {$buffer ne ""} {
1049
+ puts stderr "KIMI_TUI_STATE:$state:unknown"
1050
+ exit $unknownCode
1051
+ }
1052
+ puts stderr "KIMI_TUI_STATE:$state:timeout"
1053
+ exit $timeoutCode
1054
+ }
1055
+
1056
+ proc failEof {state code} {
1057
+ puts stderr "KIMI_TUI_STATE:$state:eof"
1058
+ exit $code
1059
+ }
1060
+
1061
+ proc directoryTrust {} {
1062
+ puts stderr "KIMI_TUI_STATE:directory-trust:manual-required"
1063
+ exit 80
1064
+ }
1065
+
1066
+ proc submitCommand {command} {
1067
+ send -- "\\033\\[200~"
1068
+ send -- $command
1069
+ send -- "\\033\\[201~"
1070
+ send -- "\\033\\[13u"
1071
+ }
1072
+
925
1073
  spawn $kimiCommand
926
- expect -re $promptPattern
927
- ${removeCommand}send -- "/plugins install $installUrl\\r"
1074
+ if {[catch {exec stty columns 240 rows 60 < $spawn_out(slave,name)} resizeError]} {
1075
+ puts stderr "KIMI_TUI_STATE:terminal-size:failed"
1076
+ exit 131
1077
+ }
928
1078
  expect {
929
- -nocase -re {trust and install} {
930
- expect {
931
- -ex $installUrl { send -- "\\033\\[B\\r" }
932
- timeout { exit 97 }
933
- eof { exit 98 }
1079
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1080
+ -re $promptPattern {}
1081
+ timeout { failTimeout initial-prompt 101 103 }
1082
+ eof { failEof initial-prompt 102 }
1083
+ }
1084
+ ${removeCommand}submitCommand "/plugins install $installUrl"
1085
+ set dialogBuffer ""
1086
+ expect {
1087
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1088
+ -nocase -re {(?:Install third-party plugin|Trust and install from)[ \\t]} {
1089
+ append dialogBuffer $expect_out(buffer)
1090
+ }
1091
+ timeout { failTimeout plugin-trust-anchor 104 106 }
1092
+ eof { failEof plugin-trust-anchor 105 }
1093
+ }
1094
+ expect {
1095
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1096
+ -nocase -re {❯[^\\r\\n]*(?:Exit|Cancel|Trust and install)} {
1097
+ append dialogBuffer $expect_out(buffer)
1098
+ }
1099
+ -re {❯[^\\r\\n]*\\r*\\n} {
1100
+ puts stderr "KIMI_TUI_STATE:plugin-trust-selected-row:unknown"
1101
+ exit 134
1102
+ }
1103
+ timeout {
1104
+ puts stderr "KIMI_TUI_STATE:plugin-trust-selected-row:timeout"
1105
+ exit 132
1106
+ }
1107
+ eof { failEof plugin-trust-selected-row 133 }
1108
+ }
1109
+
1110
+ set dialog [extractPluginTrustDialog $dialogBuffer]
1111
+ if {$dialog eq ""} {
1112
+ puts stderr "KIMI_TUI_STATE:plugin-trust:dialog-unknown"
1113
+ exit 113
1114
+ }
1115
+ set identity [extractPluginTrustIdentity $dialog]
1116
+ set compactIdentity [compactScreen $identity]
1117
+ if {$identity eq "" || $compactIdentity ne [compactScreen $installUrl]} {
1118
+ set expectedRepoPrefix "[compactScreen $expectedRepo]/releases/tag/"
1119
+ if {[string first $expectedRepoPrefix $compactIdentity] != 0} {
1120
+ puts stderr "KIMI_TUI_STATE:plugin-trust:repo-mismatch"
1121
+ exit 111
1122
+ }
1123
+ puts stderr "KIMI_TUI_STATE:plugin-trust:tag-mismatch"
1124
+ exit 112
1125
+ }
1126
+ if {[regexp -nocase {(^|\\n)[^\\n]*❯[^\\n]*trust and install} $dialog]} {
1127
+ send -- "\\033\\[13u"
1128
+ } elseif {[regexp -nocase {(^|\\n)[^\\n]*❯[^\\n]*(cancel|exit)} $dialog]} {
1129
+ send -- "\\033\\[B"
1130
+ expect {
1131
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1132
+ -nocase -re {❯[^\\r\\n]*Trust and install} {}
1133
+ -re {❯[^\\r\\n]*\\r*\\n} {
1134
+ puts stderr "KIMI_TUI_STATE:plugin-trust-confirm-selection:unknown"
1135
+ exit 109
934
1136
  }
1137
+ timeout { failTimeout plugin-trust-confirm-selection 107 109 }
1138
+ eof { failEof plugin-trust-confirm-selection 108 }
1139
+ }
1140
+ send -- "\\033\\[13u"
1141
+ } else {
1142
+ puts stderr "KIMI_TUI_STATE:plugin-trust:selection-unknown"
1143
+ exit 113
1144
+ }
1145
+
1146
+ expect {
1147
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1148
+ -nocase -re {Install finished[^\\r\\n]*see details below\\.} {}
1149
+ -nocase -re {Installing plugin from[^\\r\\n]*(?:\\r|\\n)} {
1150
+ exp_continue -continue_timer
1151
+ }
1152
+ -nocase -re {Install failed:[^\\r\\n]*} {
1153
+ puts stderr "KIMI_TUI_STATE:install-result:failed"
1154
+ exit 135
935
1155
  }
936
- timeout { exit 91 }
937
- eof { exit 92 }
1156
+ -nocase -re {(^|\\r|\\n)Install[^\\r\\n]*\\r*\\n} {
1157
+ puts stderr "KIMI_TUI_STATE:install-result:unknown"
1158
+ exit 138
1159
+ }
1160
+ timeout {
1161
+ puts stderr "KIMI_TUI_STATE:install-result:timeout"
1162
+ exit 136
1163
+ }
1164
+ eof { failEof install-result 137 }
1165
+ }
1166
+ expect {
1167
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1168
+ -re $promptPattern {}
1169
+ timeout { failTimeout post-install-prompt 114 116 }
1170
+ eof { failEof post-install-prompt 115 }
1171
+ }
1172
+ submitCommand "/reload"
1173
+ expect {
1174
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1175
+ -re $promptPattern {}
1176
+ timeout { failTimeout reload-prompt 117 119 }
1177
+ eof { failEof reload-prompt 118 }
1178
+ }
1179
+ submitCommand "/exit"
1180
+ expect {
1181
+ eof { puts stderr "KIMI_TUI_STATE:exit-eof:eof" }
1182
+ timeout { failTimeout exit-eof 120 121 }
938
1183
  }
939
- expect -re $promptPattern
940
- send -- "/reload\\r"
941
- expect -re $promptPattern
942
- send -- "/exit\\r"
943
- expect eof
944
1184
  `;
945
1185
  }
946
1186
 
@@ -1018,7 +1258,7 @@ async function runKimiUpdate(target, detected, run, kimiHome, {
1018
1258
  });
1019
1259
  return { status: 'ALREADY_CURRENT', version: target.version };
1020
1260
  }
1021
- await withTemporaryWorkspace(async (workspace) => {
1261
+ const tuiOutcome = await withTemporaryWorkspace(async (workspace) => {
1022
1262
  const checkout = join(workspace.root, 'plugin');
1023
1263
  await run('git', [
1024
1264
  'clone', '--depth', '1', '--branch', target.pluginTag, '--single-branch',
@@ -1033,18 +1273,31 @@ async function runKimiUpdate(target, detected, run, kimiHome, {
1033
1273
  }
1034
1274
  const installUrl = `https://github.com/${target.pluginRepo}/releases/tag/${target.pluginTag}`;
1035
1275
  const removePlugin = before.source === 'legacy' ? target.plugin : '';
1036
- await run(detected.expectCommand, ['-c', kimiExpectProgram({
1037
- ...(removePlugin ? { removePlugin } : {}),
1038
- })], {
1039
- timeout: 240_000,
1040
- env: {
1041
- ...env,
1042
- RELEASE_SKILL_KIMI_COMMAND: detected.command,
1043
- RELEASE_SKILL_KIMI_INSTALL_URL: installUrl,
1044
- RELEASE_SKILL_KIMI_REMOVE_PLUGIN: removePlugin,
1045
- },
1046
- });
1276
+ try {
1277
+ await run(detected.expectCommand, ['-c', kimiExpectProgram({
1278
+ ...(removePlugin ? { removePlugin } : {}),
1279
+ })], {
1280
+ timeout: Math.max(300_000, target.timeoutMs),
1281
+ env: {
1282
+ ...env,
1283
+ RELEASE_SKILL_KIMI_COMMAND: detected.command,
1284
+ RELEASE_SKILL_KIMI_INSTALL_URL: installUrl,
1285
+ RELEASE_SKILL_KIMI_REMOVE_PLUGIN: removePlugin,
1286
+ RELEASE_SKILL_KIMI_EXPECTED_REPO: `https://github.com/${target.pluginRepo}`,
1287
+ RELEASE_SKILL_KIMI_EXPECTED_TAG: target.pluginTag,
1288
+ },
1289
+ });
1290
+ } catch (error) {
1291
+ if (error?.exitStatus === 80) {
1292
+ return {
1293
+ status: 'MANUAL_REQUIRED',
1294
+ reason: 'Kimi requires folder trust; release-finish did not confirm the folder or continue installation',
1295
+ };
1296
+ }
1297
+ throw error;
1298
+ }
1047
1299
  }, { prefix: 'release-skill-kimi-update-' });
1300
+ if (tuiOutcome) return tuiOutcome;
1048
1301
  const after = await observeKimiTarget(target, kimiHome, run);
1049
1302
  if (!after.exact) throw new Error('Kimi did not report the frozen plugin identity after TUI installation');
1050
1303
  await verifyStructuredInstalledPayload({
@@ -1098,7 +1351,7 @@ async function updateLocalHostPluginsInternal({
1098
1351
  kimiHome,
1099
1352
  verifyInstalledPayload = verifyInstalledMarketplacePayload,
1100
1353
  } = {}) {
1101
- const effectiveKimiHome = kimiHome ?? process.env.KIMI_CONFIG_DIR ?? join(homedir(), '.kimi-code');
1354
+ const effectiveKimiHome = kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code');
1102
1355
  const checklist = derivePostReleaseChecklist(plan, { postVerifyComplete: true });
1103
1356
  if (confirmPlanDigest !== plan.digest) {
1104
1357
  throw new Error('plan digest confirmation does not match the frozen release plan');
@@ -8,6 +8,7 @@ import {
8
8
  effectiveHookRequiresApproval,
9
9
  normalizePostPublishView,
10
10
  postPublishActionId,
11
+ requiresPostPublishDistribution,
11
12
  } from '../core/postpublish.mjs';
12
13
  import {
13
14
  derivePostReleaseChecklist,
@@ -652,13 +653,10 @@ export async function advanceShip(options = {}, injected = {}) {
652
653
 
653
654
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
654
655
  // Step 1: Check if postPublish requires distribution.
655
- // Hooks-only declarations (no targets) still route through distribute.
656
- // §4.3 unified normalization: v3 empty arrays carry no distribute work;
657
- // legacy absent postPublish resolves to the same empty view.
656
+ // Only targets and distribute-phase hooks route through distribute;
657
+ // phase:postVerify hooks belong to their independent post-VERIFIED run.
658
658
  const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
659
- const hasDistributeWork = normalizePostPublishView(plan).some((declaration) =>
660
- (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
661
- const needsDistribution = hasDistributeWork;
659
+ const needsDistribution = requiresPostPublishDistribution(plan);
662
660
  if (needsDistribution && deps.distributeRelease) {
663
661
  state.status = 'DISTRIBUTING';
664
662
  await writeJsonAtomic(statePath, state);
@@ -23,7 +23,11 @@ import { resolveContained } from 'skill-family-harness-node';
23
23
  const execFile = promisify(execFileCb);
24
24
 
25
25
  import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from '../core/plan.mjs';
26
- import { normalizePostPublishView, postPublishActionId } from '../core/postpublish.mjs';
26
+ import {
27
+ normalizePostPublishView,
28
+ postPublishActionId,
29
+ requiresPostPublishDistribution,
30
+ } from '../core/postpublish.mjs';
27
31
  import { createEvidenceWriter } from '../core/evidence.mjs';
28
32
  import { readRunRecovery } from '../core/recovery.mjs';
29
33
  import {
@@ -1230,15 +1234,11 @@ export async function verifyRelease(options) {
1230
1234
 
1231
1235
  // =======================================================================
1232
1236
  // Step 2b: Check for postPublish distribution requirement.
1233
- // v0.6.3 R1: the gate triggers on targets OR hooks declarations, and a
1234
- // PARTIAL distribute run passes only through the blocksVerified:false
1237
+ // The gate triggers only when targets or distribute-phase hooks exist.
1238
+ // A PARTIAL distribute run passes only through the blocksVerified:false
1235
1239
  // exemption path (evaluateDistributeGateRun) — warned, never silent.
1236
1240
  // =======================================================================
1237
- // §4.3 unified normalization: v3 empty arrays mean no distribution
1238
- // requirement; legacy absent postPublish resolves to the same empty view.
1239
- const postPublishDeclarations = normalizePostPublishView(plan);
1240
- const requiresDistribution = postPublishDeclarations.some((declaration) =>
1241
- (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
1241
+ const requiresDistribution = requiresPostPublishDistribution(plan);
1242
1242
  if (requiresDistribution) {
1243
1243
  await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
1244
1244
 
@@ -547,6 +547,30 @@ export function normalizePostPublishView(plan) {
547
547
  );
548
548
  }
549
549
 
550
+ /**
551
+ * Decide whether a frozen plan has work for the distribute phase.
552
+ *
553
+ * A declaration requires distribute when it contains at least one legacy
554
+ * target, an explicit phase:distribute hook, or a hook whose omitted phase
555
+ * keeps the existing distribute default. phase:postVerify hooks belong only
556
+ * to the independent postVerify run and must not allocate an empty
557
+ * distribute predecessor.
558
+ *
559
+ * This is the single release-domain authority used by ship, verify, and
560
+ * recovery. Shape compatibility remains owned by normalizePostPublishView.
561
+ *
562
+ * @param {object} plan - Frozen release plan.
563
+ * @returns {boolean} Whether distribute must precede verify.
564
+ */
565
+ export function requiresPostPublishDistribution(plan) {
566
+ return normalizePostPublishView(plan).some((declaration) => (
567
+ (declaration.targets?.length ?? 0) > 0
568
+ || (declaration.hooks ?? []).some((hook) => (
569
+ hook.phase === undefined || hook.phase === 'distribute'
570
+ ))
571
+ ));
572
+ }
573
+
550
574
  /**
551
575
  * Array-level domain validation: every EXPLICIT hooks[].id must be unique
552
576
  * across the whole declaration array (multi-release-unit postPublish v3,
@@ -33,7 +33,12 @@ import { validatePlan, computePlanDigest, assertImmutablePlanAuthority } from '.
33
33
  import { validateApproval, validateApprovalRecordSchema, assertImmutableApprovalAuthority, computeApprovalDigest } from './approval.mjs';
34
34
  import { isMarketplaceAction } from './checkpoints.mjs';
35
35
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
36
- import { effectiveHookRequiresApproval, normalizePostPublishView, postPublishActionId } from './postpublish.mjs';
36
+ import {
37
+ effectiveHookRequiresApproval,
38
+ normalizePostPublishView,
39
+ postPublishActionId,
40
+ requiresPostPublishDistribution,
41
+ } from './postpublish.mjs';
37
42
  import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from './postpublish-approval.mjs';
38
43
 
39
44
  /** Remote errors alone are insufficient to select a safe recovery phase. */
@@ -274,11 +279,8 @@ export async function readRunRecovery(runPath, options = {}) {
274
279
  }
275
280
  } else if (['publish', 'reconcile'].includes(command)) {
276
281
  if (run.status === 'PUBLISHED') {
277
- // §4.3 unified normalization: v3 empty arrays mean no distribute work
278
- // at all; only declarations carrying targets or hooks require the
279
- // distribution phase.
280
- const needsDistribution = normalizePostPublishView(plan).some((declaration) =>
281
- (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
282
+ // phase:postVerify hooks do not create an empty distribute predecessor.
283
+ const needsDistribution = requiresPostPublishDistribution(plan);
282
284
  code = needsDistribution ? 'DISTRIBUTE' : 'VERIFY';
283
285
  } else if (run.status === 'PARTIAL') {
284
286
  code = 'RECONCILE';