release-skill 0.9.6 → 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 (38) 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 +55 -0
  8. package/INSTALL.md +17 -9
  9. package/INSTALL.zh-CN.md +15 -9
  10. package/README.md +33 -28
  11. package/README.zh-CN.md +28 -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 +357 -52
  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 +357 -52
  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 +357 -52
  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 +357 -52
  24. package/adapters/workbuddy/skills/release-finish/SKILL.md +12 -4
  25. package/bin/release-skill.bundle.mjs +357 -52
  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/prepare.mjs +5 -3
  32. package/src/commands/publish.mjs +30 -3
  33. package/src/commands/ship.mjs +4 -6
  34. package/src/commands/verify.mjs +22 -15
  35. package/src/core/postpublish.mjs +24 -0
  36. package/src/core/recovery.mjs +8 -6
  37. package/src/core/skill-resource-closure.mjs +19 -1
  38. package/src/core/surface-host-bindings.mjs +35 -0
@@ -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
 
@@ -2052,11 +2052,12 @@ export async function verifyRelease(options) {
2052
2052
  await evidence.append({ phase: 'verify', step: 'skill-resource-closure', status: 'started' });
2053
2053
 
2054
2054
  // Collect install paths from marketplace adapter checks.
2055
- // R-13 P2 (ruling 6): the surface binding is derived from the frozen
2056
- // installation contract at the REAL install coordinates — the same
2057
- // derivation rule prepare and publish use, never a hard-coded '.'.
2058
- // Plans without a frozen installation contract (legacy) fall back to
2059
- // the plugin-root surface ('.') with the legacy permissive checks.
2055
+ // R-13 P2 (ruling 6): prepare and publish derive surface bindings in
2056
+ // frozen snapshot-root coordinates. An adapter observation is already
2057
+ // rooted at the installed plugin, so verify derives the same manifest
2058
+ // skills fact relative to '.' at that consumer coordinate. Plans
2059
+ // without a frozen installation contract (legacy) fall back to the
2060
+ // plugin-root surface ('.') with the legacy permissive checks.
2060
2061
  const installSurfaces = [];
2061
2062
  for (const check of adapterChecks) {
2062
2063
  const platform = PLATFORMS.find((p) => p.actionType === check.actionType);
@@ -2065,11 +2066,16 @@ export async function verifyRelease(options) {
2065
2066
  const unit = (plan.units ?? []).find((u) => u.id === unitId);
2066
2067
  const dist = unit?.distributions?.find((d) => d.type === platform.distributionType);
2067
2068
  let binding = null;
2069
+ let frozenPluginRoot = null;
2068
2070
  const contract = dist?.installationContract;
2069
2071
  if (contract?.normalizedManifest) {
2072
+ frozenPluginRoot = pluginRootFromManifestRelativePath(contract.manifestRelativePath);
2070
2073
  binding = await deriveSurfaceHostBinding({
2071
2074
  manifest: contract.normalizedManifest,
2072
- pluginRoot: pluginRootFromManifestRelativePath(contract.manifestRelativePath),
2075
+ // Adapter observations are already rooted at the installed
2076
+ // plugin. The frozen manifest's skills path is therefore
2077
+ // relative to '.', not to the snapshot-root manifest path.
2078
+ pluginRoot: '.',
2073
2079
  platform,
2074
2080
  snapshotDir: check.observation.installPath,
2075
2081
  });
@@ -2082,6 +2088,7 @@ export async function verifyRelease(options) {
2082
2088
  unitId,
2083
2089
  surfaceId: binding?.surfaceId ?? '.',
2084
2090
  binding,
2091
+ frozenPluginRoot,
2085
2092
  unit,
2086
2093
  });
2087
2094
  }
@@ -2192,7 +2199,7 @@ export async function verifyRelease(options) {
2192
2199
  // publish's recheck uses.
2193
2200
  const pluginDistCount = (surface.unit?.distributions ?? [])
2194
2201
  .filter((d) => d.type !== 'npm').length;
2195
- if (pluginDistCount === 1) {
2202
+ if (pluginDistCount === 1 && surface.frozenPluginRoot === '.') {
2196
2203
  const observedReceipt = createSkillResourceClosureReceipt(closureResult, {
2197
2204
  unitId: surface.unitId,
2198
2205
  preparedAt: expectedUnitReceipt.preparedAt ?? null,
@@ -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';
@@ -742,9 +742,27 @@ export async function checkSkillResourceClosure({
742
742
  * @param {Array<{ id: string, host: string, skillCount: number }>} surfaces
743
743
  * @returns {{ passed: boolean, missing: Array<{ host: string, skillCount: number }> }}
744
744
  */
745
- export function evaluateDeclaredHostSurfaceCoverage(expectedHosts, surfaces) {
745
+ export function evaluateDeclaredHostSurfaceCoverage(expectedHosts, surfaces, coverageClaims = []) {
746
746
  const missing = [];
747
+ const observedById = new Map((surfaces ?? []).map((surface) => [surface.id, surface]));
748
+ const claimsByHost = new Map();
749
+ for (const claim of coverageClaims ?? []) {
750
+ const hostClaims = claimsByHost.get(claim.host) ?? [];
751
+ hostClaims.push(claim);
752
+ claimsByHost.set(claim.host, hostClaims);
753
+ }
747
754
  for (const expectedHost of [...new Set(expectedHosts ?? [])].sort((a, b) => a.localeCompare(b))) {
755
+ const declaredClaims = claimsByHost.get(expectedHost) ?? [];
756
+ if (declaredClaims.length > 0) {
757
+ const missingClaim = declaredClaims.find((claim) => {
758
+ const surface = observedById.get(claim.surfaceId);
759
+ return !surface || !(surface.skillCount >= 1);
760
+ });
761
+ if (!missingClaim) continue;
762
+ const surface = observedById.get(missingClaim.surfaceId);
763
+ missing.push({ host: expectedHost, skillCount: surface?.skillCount ?? 0 });
764
+ continue;
765
+ }
748
766
  const surface = (surfaces ?? []).find((item) => item.host === expectedHost);
749
767
  if (!surface || !(surface.skillCount >= 1)) {
750
768
  missing.push({ host: expectedHost, skillCount: surface?.skillCount ?? 0 });
@@ -186,3 +186,38 @@ export async function deriveSurfaceHostBinding({ manifest, pluginRoot, platform,
186
186
  const host = await normalizeHostId(platform.buildAdapter.name);
187
187
  return { surfaceId, host };
188
188
  }
189
+
190
+ /**
191
+ * Group manifest-backed claims before invoking the closure checker.
192
+ *
193
+ * A shared canonical source surface is scanned once. Claims remain complete
194
+ * for host coverage, while checker overrides are passed only for an
195
+ * unshared surface; this keeps the checker fail-closed when called directly
196
+ * with conflicting host claims.
197
+ *
198
+ * @param {Array<{surfaceId: string, host: string}>} claims
199
+ * @returns {{ coverageClaims: Array<{surfaceId: string, host: string}>, checkerBindings: Array<{surfaceId: string, host: string}> }}
200
+ */
201
+ export function groupSurfaceHostBindings(claims = []) {
202
+ if (!Array.isArray(claims)) {
203
+ throw new TypeError('surfaceHostBindings must be an array');
204
+ }
205
+ const bySurface = new Map();
206
+ for (const claim of claims) {
207
+ if (!claim || typeof claim !== 'object'
208
+ || typeof claim.surfaceId !== 'string' || typeof claim.host !== 'string') {
209
+ throw new TypeError('surfaceHostBindings entries must be objects with string surfaceId and host');
210
+ }
211
+ const existing = bySurface.get(claim.surfaceId) ?? [];
212
+ existing.push({ surfaceId: claim.surfaceId, host: claim.host });
213
+ bySurface.set(claim.surfaceId, existing);
214
+ }
215
+ const coverageClaims = [...bySurface.values()]
216
+ .flat()
217
+ .sort((left, right) => left.surfaceId.localeCompare(right.surfaceId) || left.host.localeCompare(right.host));
218
+ const checkerBindings = [...bySurface.values()]
219
+ .filter((surfaceClaims) => surfaceClaims.length === 1)
220
+ .map(([claim]) => claim)
221
+ .sort((left, right) => left.surfaceId.localeCompare(right.surfaceId) || left.host.localeCompare(right.host));
222
+ return { coverageClaims, checkerBindings };
223
+ }