release-skill 0.9.7 → 0.9.8

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 +27 -0
  8. package/INSTALL.md +17 -9
  9. package/INSTALL.zh-CN.md +15 -9
  10. package/README.md +32 -27
  11. package/README.zh-CN.md +27 -25
  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 +280 -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 +280 -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 +280 -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 +280 -45
  24. package/adapters/workbuddy/skills/release-finish/SKILL.md +12 -4
  25. package/bin/release-skill.bundle.mjs +280 -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 +274 -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,205 @@ 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 [string tolower $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 failTimeout {state timeoutCode unknownCode} {
1029
+ global expect_out
1030
+ set buffer ""
1031
+ if {[info exists expect_out(buffer)]} { set buffer [string trim [cleanScreen $expect_out(buffer)]] }
1032
+ if {$buffer ne ""} {
1033
+ puts stderr "KIMI_TUI_STATE:$state:unknown"
1034
+ exit $unknownCode
1035
+ }
1036
+ puts stderr "KIMI_TUI_STATE:$state:timeout"
1037
+ exit $timeoutCode
1038
+ }
1039
+
1040
+ proc failEof {state code} {
1041
+ puts stderr "KIMI_TUI_STATE:$state:eof"
1042
+ exit $code
1043
+ }
1044
+
1045
+ proc directoryTrust {} {
1046
+ puts stderr "KIMI_TUI_STATE:directory-trust:manual-required"
1047
+ exit 80
1048
+ }
1049
+
1050
+ proc submitCommand {command} {
1051
+ send -- "\\033\\[200~"
1052
+ send -- $command
1053
+ send -- "\\033\\[201~"
1054
+ send -- "\\033\\[13u"
1055
+ }
1056
+
925
1057
  spawn $kimiCommand
926
- expect -re $promptPattern
927
- ${removeCommand}send -- "/plugins install $installUrl\\r"
1058
+ if {[catch {exec stty columns 240 rows 60 < $spawn_out(slave,name)} resizeError]} {
1059
+ puts stderr "KIMI_TUI_STATE:terminal-size:failed"
1060
+ exit 131
1061
+ }
928
1062
  expect {
929
- -nocase -re {trust and install} {
930
- expect {
931
- -ex $installUrl { send -- "\\033\\[B\\r" }
932
- timeout { exit 97 }
933
- eof { exit 98 }
1063
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1064
+ -re $promptPattern {}
1065
+ timeout { failTimeout initial-prompt 101 103 }
1066
+ eof { failEof initial-prompt 102 }
1067
+ }
1068
+ ${removeCommand}submitCommand "/plugins install $installUrl"
1069
+ set dialogBuffer ""
1070
+ expect {
1071
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1072
+ -nocase -re {(?:Install third-party plugin|Trust and install from)[ \\t]} {
1073
+ append dialogBuffer $expect_out(buffer)
1074
+ }
1075
+ timeout { failTimeout plugin-trust-anchor 104 106 }
1076
+ eof { failEof plugin-trust-anchor 105 }
1077
+ }
1078
+ expect {
1079
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1080
+ -nocase -re {❯[^\\r\\n]*(?:Exit|Cancel|Trust and install)} {
1081
+ append dialogBuffer $expect_out(buffer)
1082
+ }
1083
+ -re {❯[^\\r\\n]*\\r*\\n} {
1084
+ puts stderr "KIMI_TUI_STATE:plugin-trust-selected-row:unknown"
1085
+ exit 134
1086
+ }
1087
+ timeout {
1088
+ puts stderr "KIMI_TUI_STATE:plugin-trust-selected-row:timeout"
1089
+ exit 132
1090
+ }
1091
+ eof { failEof plugin-trust-selected-row 133 }
1092
+ }
1093
+
1094
+ set dialog [extractPluginTrustDialog $dialogBuffer]
1095
+ if {$dialog eq ""} {
1096
+ puts stderr "KIMI_TUI_STATE:plugin-trust:dialog-unknown"
1097
+ exit 113
1098
+ }
1099
+ set compactDialog [compactScreen $dialog]
1100
+ if {[string first [string tolower [compactScreen $expectedRepo]] $compactDialog] < 0} {
1101
+ puts stderr "KIMI_TUI_STATE:plugin-trust:repo-mismatch"
1102
+ exit 111
1103
+ }
1104
+ if {[string first [string tolower [compactScreen $expectedTag]] $compactDialog] < 0} {
1105
+ puts stderr "KIMI_TUI_STATE:plugin-trust:tag-mismatch"
1106
+ exit 112
1107
+ }
1108
+ if {[regexp -nocase {(^|\\n)[^\\n]*❯[^\\n]*trust and install} $dialog]} {
1109
+ send -- "\\033\\[13u"
1110
+ } elseif {[regexp -nocase {(^|\\n)[^\\n]*❯[^\\n]*(cancel|exit)} $dialog]} {
1111
+ send -- "\\033\\[B"
1112
+ expect {
1113
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1114
+ -nocase -re {❯[^\\r\\n]*Trust and install} {}
1115
+ -re {❯[^\\r\\n]*\\r*\\n} {
1116
+ puts stderr "KIMI_TUI_STATE:plugin-trust-confirm-selection:unknown"
1117
+ exit 109
934
1118
  }
1119
+ timeout { failTimeout plugin-trust-confirm-selection 107 109 }
1120
+ eof { failEof plugin-trust-confirm-selection 108 }
935
1121
  }
936
- timeout { exit 91 }
937
- eof { exit 92 }
1122
+ send -- "\\033\\[13u"
1123
+ } else {
1124
+ puts stderr "KIMI_TUI_STATE:plugin-trust:selection-unknown"
1125
+ exit 113
1126
+ }
1127
+
1128
+ expect {
1129
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1130
+ -nocase -re {Install finished[^\\r\\n]*see details below\\.} {}
1131
+ -nocase -re {Installing plugin from[^\\r\\n]*(?:\\r|\\n)} {
1132
+ exp_continue -continue_timer
1133
+ }
1134
+ -nocase -re {Install failed:[^\\r\\n]*} {
1135
+ puts stderr "KIMI_TUI_STATE:install-result:failed"
1136
+ exit 135
1137
+ }
1138
+ -nocase -re {(^|\\r|\\n)Install[^\\r\\n]*\\r*\\n} {
1139
+ puts stderr "KIMI_TUI_STATE:install-result:unknown"
1140
+ exit 138
1141
+ }
1142
+ timeout {
1143
+ puts stderr "KIMI_TUI_STATE:install-result:timeout"
1144
+ exit 136
1145
+ }
1146
+ eof { failEof install-result 137 }
1147
+ }
1148
+ expect {
1149
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1150
+ -re $promptPattern {}
1151
+ timeout { failTimeout post-install-prompt 114 116 }
1152
+ eof { failEof post-install-prompt 115 }
1153
+ }
1154
+ submitCommand "/reload"
1155
+ expect {
1156
+ -nocase -re {Trust this folder\\?} { directoryTrust }
1157
+ -re $promptPattern {}
1158
+ timeout { failTimeout reload-prompt 117 119 }
1159
+ eof { failEof reload-prompt 118 }
1160
+ }
1161
+ submitCommand "/exit"
1162
+ expect {
1163
+ eof { puts stderr "KIMI_TUI_STATE:exit-eof:eof" }
1164
+ timeout { failTimeout exit-eof 120 121 }
938
1165
  }
939
- expect -re $promptPattern
940
- send -- "/reload\\r"
941
- expect -re $promptPattern
942
- send -- "/exit\\r"
943
- expect eof
944
1166
  `;
945
1167
  }
946
1168
 
@@ -1018,7 +1240,7 @@ async function runKimiUpdate(target, detected, run, kimiHome, {
1018
1240
  });
1019
1241
  return { status: 'ALREADY_CURRENT', version: target.version };
1020
1242
  }
1021
- await withTemporaryWorkspace(async (workspace) => {
1243
+ const tuiOutcome = await withTemporaryWorkspace(async (workspace) => {
1022
1244
  const checkout = join(workspace.root, 'plugin');
1023
1245
  await run('git', [
1024
1246
  'clone', '--depth', '1', '--branch', target.pluginTag, '--single-branch',
@@ -1033,18 +1255,31 @@ async function runKimiUpdate(target, detected, run, kimiHome, {
1033
1255
  }
1034
1256
  const installUrl = `https://github.com/${target.pluginRepo}/releases/tag/${target.pluginTag}`;
1035
1257
  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
- });
1258
+ try {
1259
+ await run(detected.expectCommand, ['-c', kimiExpectProgram({
1260
+ ...(removePlugin ? { removePlugin } : {}),
1261
+ })], {
1262
+ timeout: Math.max(300_000, target.timeoutMs),
1263
+ env: {
1264
+ ...env,
1265
+ RELEASE_SKILL_KIMI_COMMAND: detected.command,
1266
+ RELEASE_SKILL_KIMI_INSTALL_URL: installUrl,
1267
+ RELEASE_SKILL_KIMI_REMOVE_PLUGIN: removePlugin,
1268
+ RELEASE_SKILL_KIMI_EXPECTED_REPO: `https://github.com/${target.pluginRepo}`,
1269
+ RELEASE_SKILL_KIMI_EXPECTED_TAG: target.pluginTag,
1270
+ },
1271
+ });
1272
+ } catch (error) {
1273
+ if (error?.exitStatus === 80) {
1274
+ return {
1275
+ status: 'MANUAL_REQUIRED',
1276
+ reason: 'Kimi requires folder trust; release-finish did not confirm the folder or continue installation',
1277
+ };
1278
+ }
1279
+ throw error;
1280
+ }
1047
1281
  }, { prefix: 'release-skill-kimi-update-' });
1282
+ if (tuiOutcome) return tuiOutcome;
1048
1283
  const after = await observeKimiTarget(target, kimiHome, run);
1049
1284
  if (!after.exact) throw new Error('Kimi did not report the frozen plugin identity after TUI installation');
1050
1285
  await verifyStructuredInstalledPayload({
@@ -1098,7 +1333,7 @@ async function updateLocalHostPluginsInternal({
1098
1333
  kimiHome,
1099
1334
  verifyInstalledPayload = verifyInstalledMarketplacePayload,
1100
1335
  } = {}) {
1101
- const effectiveKimiHome = kimiHome ?? process.env.KIMI_CONFIG_DIR ?? join(homedir(), '.kimi-code');
1336
+ const effectiveKimiHome = kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code');
1102
1337
  const checklist = derivePostReleaseChecklist(plan, { postVerifyComplete: true });
1103
1338
  if (confirmPlanDigest !== plan.digest) {
1104
1339
  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';