release-skill 0.5.0 → 0.6.0

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 (121) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +31 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +13 -21
  10. package/README.zh-CN.md +14 -22
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +1 -1
  14. package/adapters/claude/bin/kernel-protocol.json +1 -1
  15. package/adapters/claude/bin/registry.json +15 -1
  16. package/adapters/claude/bin/release-skill.bundle.mjs +38147 -23284
  17. package/adapters/claude/bin/rules.json +1 -1
  18. package/adapters/claude/schemas/.render-manifest.json +6 -6
  19. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  20. package/adapters/claude/schemas/release-project.schema.json +291 -0
  21. package/adapters/claude/schemas/release-run.schema.json +59 -6
  22. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  23. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  24. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  25. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  26. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  27. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  28. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  29. package/adapters/codex/bin/error-codes.json +1 -1
  30. package/adapters/codex/bin/kernel-protocol.json +1 -1
  31. package/adapters/codex/bin/registry.json +15 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +38147 -23284
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +6 -6
  35. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  36. package/adapters/codex/schemas/release-project.schema.json +291 -0
  37. package/adapters/codex/schemas/release-run.schema.json +59 -6
  38. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  39. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  40. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  41. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  42. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  43. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  44. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  45. package/adapters/kimi/bin/error-codes.json +1 -1
  46. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  47. package/adapters/kimi/bin/registry.json +15 -1
  48. package/adapters/kimi/bin/release-skill.bundle.mjs +38147 -23284
  49. package/adapters/kimi/bin/rules.json +1 -1
  50. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  51. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  52. package/adapters/kimi/schemas/release-project.schema.json +291 -0
  53. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  54. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  55. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  56. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  57. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  58. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  60. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  61. package/adapters/workbuddy/bin/error-codes.json +1 -1
  62. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  63. package/adapters/workbuddy/bin/registry.json +15 -1
  64. package/adapters/workbuddy/bin/release-skill.bundle.mjs +38147 -23284
  65. package/adapters/workbuddy/bin/rules.json +1 -1
  66. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  67. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  68. package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
  69. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  70. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  71. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  72. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  73. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  74. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  75. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  76. package/bin/error-codes.json +1 -1
  77. package/bin/kernel-protocol.json +1 -1
  78. package/bin/registry.json +15 -1
  79. package/bin/release-skill-cli.mjs +235 -7
  80. package/bin/release-skill.bundle.mjs +38147 -23284
  81. package/bin/rules.json +1 -1
  82. package/package.json +5 -4
  83. package/platform-manifest.json +359 -0
  84. package/references/.render-manifest.json +9 -9
  85. package/references/02-project-config.md +35 -0
  86. package/references/05-evidence-and-errors.md +44 -0
  87. package/references/06-adapter-contract.md +13 -0
  88. package/schemas/.render-manifest.json +6 -6
  89. package/schemas/release-plan.schema.json +401 -2
  90. package/schemas/release-project.schema.json +291 -0
  91. package/schemas/release-run.schema.json +59 -6
  92. package/skills/release-config/SKILL.md +140 -0
  93. package/skills/release-docs/SKILL.md +106 -0
  94. package/skills/release-help/SKILL.md +39 -0
  95. package/skills/release-marketplace/SKILL.md +147 -0
  96. package/skills/release-publish/SKILL.md +25 -6
  97. package/skills/release-verify/SKILL.md +10 -0
  98. package/skills-src/release-config/SKILL.md +140 -0
  99. package/skills-src/release-docs/SKILL.md +106 -0
  100. package/skills-src/release-help/SKILL.md +39 -0
  101. package/skills-src/release-marketplace/SKILL.md +147 -0
  102. package/skills-src/release-publish/SKILL.md +25 -6
  103. package/skills-src/release-verify/SKILL.md +10 -0
  104. package/src/adapters/contract.mjs +5 -0
  105. package/src/adapters/distribute-git.mjs +625 -0
  106. package/src/adapters/plugin-marketplace.mjs +48 -4
  107. package/src/adapters/push-snapshot.mjs +12 -4
  108. package/src/commands/assess.mjs +171 -0
  109. package/src/commands/distribute.mjs +1078 -0
  110. package/src/commands/hooks.mjs +6 -1
  111. package/src/commands/lineage.mjs +616 -0
  112. package/src/commands/prepare.mjs +413 -72
  113. package/src/commands/route.mjs +686 -0
  114. package/src/commands/ship.mjs +45 -1
  115. package/src/commands/verify.mjs +176 -0
  116. package/src/core/baseline-advance.mjs +185 -0
  117. package/src/core/baseline.mjs +11 -1
  118. package/src/core/checkpoints.mjs +25 -0
  119. package/src/core/foundation-inflight.mjs +7 -0
  120. package/src/core/plan.mjs +12 -2
  121. package/src/core/postpublish.mjs +315 -0
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
22
- import { readFile, mkdir, realpath } from 'node:fs/promises';
22
+ import { readFile, mkdir, readdir, realpath } from 'node:fs/promises';
23
23
  import { execFile as execFileCb } from 'node:child_process';
24
24
  import { promisify } from 'node:util';
25
25
 
@@ -67,6 +67,7 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
67
67
  import { PLATFORMS } from '../platforms/registry.mjs';
68
68
  import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
69
69
  import { buildInstallationContract, computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from '../core/installation-contract.mjs';
70
+ import { validatePostPublishDeclaration, PAYLOAD_SOURCE_TAG_WORKTREE } from '../core/postpublish.mjs';
70
71
 
71
72
  // ---------------------------------------------------------------------------
72
73
  // 安装契约常量
@@ -243,6 +244,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
243
244
  * @param {object} [options]
244
245
  * @param {boolean} [options.hookCache=true] - When false (--no-hook-cache),
245
246
  * every hook runs in full and the cache is neither read nor written.
247
+ * @param {Record<string, string>} [options.env] - Explicit environment map
248
+ * merged into the hook context (`hookFn(hook, { root, env })`). The hook
249
+ * runner's `buildFilteredEnv` reads `envAllowlist` keys exclusively from
250
+ * this map (never from process.env), so the caller decides what is
251
+ * injectable. Defaults to process.env at the prepare call site, which makes
252
+ * allowlisted keys exported by the invoking shell reach the hook
253
+ * subprocess.
246
254
  * @returns {Promise<void>}
247
255
  * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
248
256
  * throws, or declares a cacheInputs glob that matches no file.
@@ -294,7 +302,10 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
294
302
 
295
303
  let result;
296
304
  try {
297
- result = await hookFn(hook, { root });
305
+ result = await hookFn(hook, {
306
+ root,
307
+ ...(options.env !== undefined ? { env: options.env } : {}),
308
+ });
298
309
  } catch (err) {
299
310
  await evidence.append({
300
311
  phase: 'hooks',
@@ -1683,6 +1694,43 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1683
1694
  *
1684
1695
  * @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
1685
1696
  */
1697
+
1698
+ /**
1699
+ * Read the newest frozen plan from `.release-skill/plans/` (digest-addressed
1700
+ * immutable plans written by writePlanImmutable). Used by the config workflow
1701
+ * decision to compare public bytes (per-unit snapshotDigest) against the
1702
+ * latest frozen state. Returns null when no readable plan exists.
1703
+ *
1704
+ * @param {string} root - project root directory
1705
+ * @returns {Promise<{ plan: object, fileName: string } | null>}
1706
+ */
1707
+ export async function readLatestFrozenPlan(root) {
1708
+ const plansDir = resolve(root, '.release-skill', 'plans');
1709
+ let files;
1710
+ try {
1711
+ files = await readdir(plansDir);
1712
+ } catch {
1713
+ return null;
1714
+ }
1715
+ let best = null;
1716
+ for (const file of files.sort()) {
1717
+ if (!file.endsWith('.json')) continue;
1718
+ let plan;
1719
+ try {
1720
+ plan = JSON.parse(await readFile(resolve(plansDir, file), 'utf8'));
1721
+ } catch {
1722
+ continue;
1723
+ }
1724
+ if (!plan || typeof plan !== 'object' || !Array.isArray(plan.units)) continue;
1725
+ const ts = plan.createdAt ? new Date(plan.createdAt).getTime() : NaN;
1726
+ const effectiveTs = Number.isFinite(ts) ? ts : 0;
1727
+ if (!best || effectiveTs > best.ts) {
1728
+ best = { plan, fileName: file, ts: effectiveTs };
1729
+ }
1730
+ }
1731
+ return best ? { plan: best.plan, fileName: best.fileName } : null;
1732
+ }
1733
+
1686
1734
  export async function prepareRelease(options) {
1687
1735
  const {
1688
1736
  root,
@@ -1694,9 +1742,31 @@ export async function prepareRelease(options) {
1694
1742
  hooksAuthorized,
1695
1743
  verificationGatesAuthorized,
1696
1744
  production = false,
1745
+ workflow = 'full',
1697
1746
  observePreviousPublicBaselineFn,
1698
1747
  } = options ?? {};
1699
1748
 
1749
+ // --- Workflow profile (H5) ---
1750
+ // 'full' runs the complete gate set. 'docs', 'config' and 'marketplace'
1751
+ // trim only code-class gates — declared hooks, snapshot-verify gates,
1752
+ // source-authority closure, and skill-resource-closure — and record the
1753
+ // trim as `workflowDecision` bound into the plan digest. All other gates
1754
+ // (docs freshness, public surface, baseline, snapshots, remote checks,
1755
+ // plan freeze, consumer-verify gates) run identically for every workflow.
1756
+ const WORKFLOW_KINDS = new Set(['full', 'docs', 'config', 'marketplace']);
1757
+ if (!WORKFLOW_KINDS.has(workflow)) {
1758
+ throw new ReleaseError(
1759
+ CONFIG_INVALID,
1760
+ `unknown workflow kind "${workflow}"; expected one of ${[...WORKFLOW_KINDS].sort().join(', ')}`,
1761
+ { workflow },
1762
+ );
1763
+ }
1764
+ const trimmedWorkflow = workflow !== 'full';
1765
+ const skipDeclaredHooks = trimmedWorkflow;
1766
+ const skipSnapshotVerifyGates = trimmedWorkflow;
1767
+ const skipSourceAuthorityClosure = trimmedWorkflow;
1768
+ const skipSkillResourceClosure = trimmedWorkflow;
1769
+
1700
1770
  // --- Validate root ---
1701
1771
  if (!root || typeof root !== 'string') {
1702
1772
  throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
@@ -1775,6 +1845,28 @@ export async function prepareRelease(options) {
1775
1845
  });
1776
1846
  }
1777
1847
 
1848
+ // --- Step 1a: Workflow configuration evidence ---
1849
+ // Records the deterministic trim set for docs/config/marketplace
1850
+ // workflows. The trim never weakens the retained gates; it only removes
1851
+ // code-class gates that a non-code change surface cannot exercise.
1852
+ await evidence.append({
1853
+ phase: 'workflow',
1854
+ status: 'configured',
1855
+ workflowKind: workflow,
1856
+ trimmedGates: trimmedWorkflow
1857
+ ? ['declared-hooks', 'snapshot-verify-gates', 'source-authority-closure', 'skill-resource-closure']
1858
+ : [],
1859
+ retainedGates: [
1860
+ 'docs-freshness',
1861
+ 'public-surface',
1862
+ 'baseline',
1863
+ 'snapshots',
1864
+ 'remote-check',
1865
+ 'plan-freeze',
1866
+ 'consumer-verify',
1867
+ ],
1868
+ });
1869
+
1778
1870
  // --- Step 1b: Resolve authoritative versions and gate release-document
1779
1871
  // freshness BEFORE hook authorization ---
1780
1872
  // Authoritative versions resolve exactly once here and are reused by
@@ -1801,11 +1893,46 @@ export async function prepareRelease(options) {
1801
1893
  reasonTag: 'RELEASE_DOCS_STALE',
1802
1894
  });
1803
1895
 
1896
+ // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
1897
+ // The per-unit postPublish block drives the post-publish distribute
1898
+ // command. Validate it here, before any hook, baseline, snapshot, remote
1899
+ // check, or plan write, so an unsafe declaration fails closed with zero
1900
+ // side effects. This is the runtime re-check on top of the config JSON
1901
+ // schema: plans frozen by older schema versions must not be able to
1902
+ // smuggle shell strings, option-like executables, or secret-ish env
1903
+ // keys through. A plan binds exactly one declaration; multiple units
1904
+ // declaring postPublish is a hard gate failure.
1905
+ const postPublishDeclarations = configUnits
1906
+ .map((unit, index) => ({ unit, index }))
1907
+ .filter(({ unit }) => unit.postPublish !== undefined);
1908
+ if (postPublishDeclarations.length > 1) {
1909
+ throw new ReleaseError(
1910
+ GATE_FAILED,
1911
+ `multiple units declare postPublish (${postPublishDeclarations.map(({ unit }) => unit.id).join(', ')}); a release plan binds exactly one postPublish declaration`,
1912
+ { unitIds: postPublishDeclarations.map(({ unit }) => unit.id) },
1913
+ );
1914
+ }
1915
+ let postPublishDeclaration = null;
1916
+ if (postPublishDeclarations.length === 1) {
1917
+ const { unit, index } = postPublishDeclarations[0];
1918
+ validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
1919
+ postPublishDeclaration = { unit, index };
1920
+ await evidence.append({
1921
+ phase: 'postpublish-declaration',
1922
+ status: 'validated',
1923
+ unitId: unit.id,
1924
+ targetCount: unit.postPublish.targets.length,
1925
+ });
1926
+ }
1927
+
1804
1928
  // --- Step 2: Hook authorization gate ---
1805
1929
  // Hooks are user-configured arbitrary local processes without filesystem
1806
1930
  // or network isolation. They may write outside the project, access local
1807
1931
  // credentials, or make network calls. The user must explicitly accept
1808
1932
  // these risks before any hook is executed.
1933
+ // docs/config/marketplace workflows trim code-class hooks entirely
1934
+ // (H5): a non-code change surface cannot exercise them, so they are
1935
+ // recorded as skipped rather than authorized-and-run.
1809
1936
  const declaredHooks = Object.entries(config.hooks ?? {})
1810
1937
  .filter(([, hook]) => hook && hook.command)
1811
1938
  .map(([name, hook]) => ({
@@ -1819,7 +1946,7 @@ export async function prepareRelease(options) {
1819
1946
  // execution of configured commands. Old acknowledgement parameters
1820
1947
  // (--acknowledge-hook-side-effects, --acknowledge-gate-side-effects) are
1821
1948
  // accepted as no-effect compatibility inputs but are not required.
1822
- if (declaredHooks.length > 0) {
1949
+ if (!skipDeclaredHooks && declaredHooks.length > 0) {
1823
1950
  await evidence.append({
1824
1951
  phase: 'hook-authorization',
1825
1952
  status: 'authorized',
@@ -1847,11 +1974,24 @@ export async function prepareRelease(options) {
1847
1974
  }
1848
1975
 
1849
1976
  // --- Step 3: Run declared hooks ---
1850
- await evidence.append({ phase: 'hooks', status: 'started' });
1851
- await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1852
- hookCache: options.hookCache,
1853
- });
1854
- await evidence.append({ phase: 'hooks', status: 'completed' });
1977
+ if (skipDeclaredHooks) {
1978
+ await evidence.append({
1979
+ phase: 'hooks',
1980
+ status: 'skipped',
1981
+ reason: `workflow "${workflow}" trims code-class hooks; declared hooks: ${declaredHooks.length}`,
1982
+ });
1983
+ } else {
1984
+ await evidence.append({ phase: 'hooks', status: 'started' });
1985
+ await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1986
+ hookCache: options.hookCache,
1987
+ // Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
1988
+ // reads envAllowlist keys exclusively from context.env, so the invoking
1989
+ // shell's environment is injected here explicitly. Allowlist semantics
1990
+ // are unchanged — only allowlisted keys from this map reach the child.
1991
+ env: options.env ?? process.env,
1992
+ });
1993
+ await evidence.append({ phase: 'hooks', status: 'completed' });
1994
+ }
1855
1995
 
1856
1996
  // --- Step 3b: Re-check release-document freshness AFTER hooks ---
1857
1997
  // Declared hooks run as arbitrary local processes; they may rewrite a
@@ -1953,7 +2093,7 @@ export async function prepareRelease(options) {
1953
2093
 
1954
2094
  let sourceAuthority = null;
1955
2095
  let sourceInputClosure = null;
1956
- if (production) {
2096
+ if (production && !skipSourceAuthorityClosure) {
1957
2097
  if (!sourceRepository || typeof sourceRepository !== 'string') {
1958
2098
  throw new ReleaseError(
1959
2099
  CONFIG_MISSING,
@@ -2023,6 +2163,12 @@ export async function prepareRelease(options) {
2023
2163
  inputDigest: sourceInputClosure.digest,
2024
2164
  remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
2025
2165
  });
2166
+ } else if (production) {
2167
+ await evidence.append({
2168
+ phase: 'source-authority',
2169
+ status: 'skipped',
2170
+ reason: `workflow "${workflow}" trims the source-authority content closure gate`,
2171
+ });
2026
2172
  }
2027
2173
 
2028
2174
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
@@ -2077,6 +2223,29 @@ export async function prepareRelease(options) {
2077
2223
  }
2078
2224
  };
2079
2225
  const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
2226
+ // CHAIN_GAP detection (0.5.1 chain hardening): when a unit declares
2227
+ // previousPublicBaseline.mode=none (first-release semantics) but the
2228
+ // public repository already carries release tags for the unit's
2229
+ // tagTemplate, a subsequent release pretending to be the first would
2230
+ // freeze a plan whose push-snapshot creates a new orphan root commit.
2231
+ // Detection is online-only: `git ls-remote` on the tag pattern
2232
+ // (tagTemplate with {version} → *). Any match proves a prior release.
2233
+ const defaultPriorReleaseTagDetector = async (repo, tagPattern, { githubHost = 'github.com' } = {}) => {
2234
+ try {
2235
+ const { stdout } = await execFile(
2236
+ 'git',
2237
+ ['ls-remote', `https://${githubHost}/${repo}.git`, `refs/tags/${tagPattern}`],
2238
+ { shell: false, encoding: 'utf8', timeout: 30000 },
2239
+ );
2240
+ return { found: stdout.trim().length > 0 };
2241
+ } catch (err) {
2242
+ // Network/auth failure: unknown, not "no prior release". The caller
2243
+ // records a warning and continues (consistent with the bound-baseline
2244
+ // observer's unknown status); it never silently proves first release.
2245
+ return { error: err.message };
2246
+ }
2247
+ };
2248
+ const detectPriorReleaseTags = options.detectPriorReleaseTagsFn ?? defaultPriorReleaseTagDetector;
2080
2249
  const unitBaselineResults = new Map();
2081
2250
  for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
2082
2251
  const unit = configUnits[unitIndex];
@@ -2120,6 +2289,50 @@ export async function prepareRelease(options) {
2120
2289
  });
2121
2290
 
2122
2291
  if (ppbConfig.mode === "none") {
2292
+ // Chain-integrity gate (0.5.1, CHAIN_GAP): a production online
2293
+ // prepare must not freeze a first-release plan for a repository that
2294
+ // has already published. This is the orphan-root channel that caused
2295
+ // the synthetic 2000-01-01 root commits in the historical public
2296
+ // repositories: first releases used mode=none, push-snapshot degraded
2297
+ // to create-release-branch with no parent, and every later release
2298
+ // started a fresh lineage. Fail closed and demand a bound baseline
2299
+ // pointing at the previous release commit instead.
2300
+ if (production && !offline) {
2301
+ const tagTemplate = unit.version?.tagTemplate;
2302
+ const tagPattern = tagTemplate ? tagTemplate.replace('{version}', '*') : null;
2303
+ if (tagPattern) {
2304
+ const detection = await detectPriorReleaseTags(unit.publicRepo, tagPattern, {
2305
+ githubHost: productionGithubHost,
2306
+ });
2307
+ if (detection.found) {
2308
+ await evidence.append({
2309
+ phase: "previous-public-baseline",
2310
+ unitId: unit.id,
2311
+ status: "blocking",
2312
+ reason: "CHAIN_GAP",
2313
+ repo: unit.publicRepo,
2314
+ tagPattern,
2315
+ guidance: "非首次发布:必须把 previousPublicBaseline 绑定到上一发布提交(mode=bound),不能以 mode=none 制造新的孤儿根提交",
2316
+ });
2317
+ throw new ReleaseError(
2318
+ GATE_FAILED,
2319
+ `unit "${unit.id}" previousPublicBaseline.mode=none but the public repository already has release tags matching "${tagPattern}" (CHAIN_GAP): bind the previous public release commit as a bound baseline`,
2320
+ { unitId: unit.id, reason: 'CHAIN_GAP', repo: unit.publicRepo, tagPattern },
2321
+ );
2322
+ }
2323
+ if (detection.error) {
2324
+ await evidence.append({
2325
+ phase: "previous-public-baseline",
2326
+ unitId: unit.id,
2327
+ status: "warning",
2328
+ reason: "CHAIN_GAP_DETECTION_FAILED",
2329
+ repo: unit.publicRepo,
2330
+ tagPattern,
2331
+ error: detection.error,
2332
+ });
2333
+ }
2334
+ }
2335
+ }
2123
2336
  unitBaselineResults.set(unit.id, {
2124
2337
  mode: "none",
2125
2338
  status: "consistent",
@@ -2276,24 +2489,38 @@ export async function prepareRelease(options) {
2276
2489
  // Snapshot gates always run on disposable writable copies. The public
2277
2490
  // snapshot authority is re-digested after every gate and is never exposed
2278
2491
  // as the gate working directory.
2279
- const snapshotGateResults = await runSnapshotVerificationGates({
2280
- gates: declaredVerificationGates,
2281
- unitResults,
2282
- runDir,
2283
- evidence,
2284
- env: options.gateEnv ?? process.env,
2285
- });
2286
- await evidence.append({
2287
- phase: 'snapshot-verify',
2288
- status: 'completed',
2289
- gateCount: snapshotGateResults.length,
2290
- });
2492
+ // docs/config/marketplace workflows trim snapshot-verify gates (H5):
2493
+ // they are declared code-class gates that a non-code change surface
2494
+ // cannot exercise. Consumer-verify gates (phase 'consumer-verify') are
2495
+ // NOT part of this skip — they run during verify regardless of workflow.
2496
+ if (skipSnapshotVerifyGates) {
2497
+ await evidence.append({
2498
+ phase: 'snapshot-verify',
2499
+ status: 'skipped',
2500
+ reason: `workflow "${workflow}" trims snapshot-verify gates; declared gates: ${declaredVerificationGates.length}`,
2501
+ });
2502
+ } else {
2503
+ const snapshotGateResults = await runSnapshotVerificationGates({
2504
+ gates: declaredVerificationGates,
2505
+ unitResults,
2506
+ runDir,
2507
+ evidence,
2508
+ env: options.gateEnv ?? process.env,
2509
+ });
2510
+ await evidence.append({
2511
+ phase: 'snapshot-verify',
2512
+ status: 'completed',
2513
+ gateCount: snapshotGateResults.length,
2514
+ });
2515
+ }
2291
2516
 
2292
2517
  // Bind the remote source-authority proof to the exact bytes that entered
2293
2518
  // the frozen snapshots, not merely to an earlier read of the workspace.
2294
2519
  // Then re-read the complete closure and dirty state once more so version
2295
2520
  // sources and non-snapshot closure entries cannot drift during prepare.
2296
- if (production) {
2521
+ // Trimmed together with Step 3c when a docs/config/marketplace workflow
2522
+ // skips the source-authority closure.
2523
+ if (production && !skipSourceAuthorityClosure) {
2297
2524
  const snapshotSourceResult = verifySnapshotSourcesMatchClosure({
2298
2525
  closure: sourceInputClosure,
2299
2526
  unitResults,
@@ -2420,59 +2647,72 @@ export async function prepareRelease(options) {
2420
2647
  // publish will re-verify, rather than the writable pre-freeze staging tree.
2421
2648
  // Non-production plans scan the final staging tree at the same point.
2422
2649
  // This gate is built in, read-only, and cannot be disabled by overlays.
2650
+ // docs/config/marketplace workflows trim it (H5): the plan omits
2651
+ // `skillResourceClosure` entirely, so publish's conditional re-check
2652
+ // (guarded by plan.skillResourceClosure) and verify's receipt comparison
2653
+ // both skip it consistently. The trim is recorded in workflowDecision.
2423
2654
  const skillResourceClosureResults = [];
2424
- for (const { unit, manifest } of unitResults) {
2655
+ if (skipSkillResourceClosure) {
2425
2656
  await evidence.append({
2426
2657
  phase: 'skill-resource-closure',
2427
- status: 'started',
2428
- unitId: unit.id,
2658
+ status: 'skipped',
2659
+ reason: `workflow "${workflow}" trims the skill resource closure gate`,
2660
+ unitCount: unitResults.length,
2429
2661
  });
2662
+ } else {
2663
+ for (const { unit, manifest } of unitResults) {
2664
+ await evidence.append({
2665
+ phase: 'skill-resource-closure',
2666
+ status: 'started',
2667
+ unitId: unit.id,
2668
+ });
2430
2669
 
2431
- const closureResult = await checkSkillResourceClosure({
2432
- snapshotDir: manifest.outputDir,
2433
- host: 'root',
2434
- });
2670
+ const closureResult = await checkSkillResourceClosure({
2671
+ snapshotDir: manifest.outputDir,
2672
+ host: 'root',
2673
+ });
2674
+
2675
+ const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2676
+ skillResourceClosureResults.push(receipt);
2435
2677
 
2436
- const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2437
- skillResourceClosureResults.push(receipt);
2678
+ if (closureResult.findings.length > 0) {
2679
+ await evidence.append({
2680
+ phase: 'skill-resource-closure',
2681
+ status: 'blocking',
2682
+ unitId: unit.id,
2683
+ findingCount: closureResult.findings.length,
2684
+ findings: closureResult.findings.map((f) => ({
2685
+ skill: f.skill,
2686
+ line: f.line,
2687
+ reference: f.reference,
2688
+ classification: f.classification,
2689
+ code: f.code,
2690
+ })),
2691
+ });
2692
+ throw new ReleaseError(
2693
+ GATE_FAILED,
2694
+ `skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
2695
+ {
2696
+ unitId: unit.id,
2697
+ findingCount: closureResult.findings.length,
2698
+ findings: closureResult.findings,
2699
+ },
2700
+ );
2701
+ }
2438
2702
 
2439
- if (closureResult.findings.length > 0) {
2440
2703
  await evidence.append({
2441
2704
  phase: 'skill-resource-closure',
2442
- status: 'blocking',
2705
+ status: 'completed',
2443
2706
  unitId: unit.id,
2444
- findingCount: closureResult.findings.length,
2445
- findings: closureResult.findings.map((f) => ({
2446
- skill: f.skill,
2447
- line: f.line,
2448
- reference: f.reference,
2449
- classification: f.classification,
2450
- code: f.code,
2451
- })),
2707
+ checkerVersion: closureResult.checkerVersion,
2708
+ surfaceCount: receipt.surfaceCount,
2709
+ skillCount: receipt.skillCount,
2710
+ referenceCount: closureResult.referenceCount,
2711
+ sourceOnlyCount: closureResult.sourceOnlyCount,
2712
+ findingCount: 0,
2713
+ receiptDigest: closureResult.receiptDigest,
2452
2714
  });
2453
- throw new ReleaseError(
2454
- GATE_FAILED,
2455
- `skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
2456
- {
2457
- unitId: unit.id,
2458
- findingCount: closureResult.findings.length,
2459
- findings: closureResult.findings,
2460
- },
2461
- );
2462
2715
  }
2463
-
2464
- await evidence.append({
2465
- phase: 'skill-resource-closure',
2466
- status: 'completed',
2467
- unitId: unit.id,
2468
- checkerVersion: closureResult.checkerVersion,
2469
- surfaceCount: receipt.surfaceCount,
2470
- skillCount: receipt.skillCount,
2471
- referenceCount: closureResult.referenceCount,
2472
- sourceOnlyCount: closureResult.sourceOnlyCount,
2473
- findingCount: 0,
2474
- receiptDigest: closureResult.receiptDigest,
2475
- });
2476
2716
  }
2477
2717
 
2478
2718
  // Freeze external independent marketplace HEADs (production + online only):
@@ -2805,20 +3045,114 @@ export async function prepareRelease(options) {
2805
3045
  // 构建冻结分发映射:unitId -> frozen distributions
2806
3046
  const frozenDistributionsMap = new Map(units.map((u) => [u.id, u.distributions]));
2807
3047
 
2808
- const externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
3048
+ let externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
2809
3049
 
2810
3050
  // Compute overall snapshot digest
2811
3051
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
2812
3052
 
3053
+ // --- Step 6b: Workflow decision (H5) ---
3054
+ // config workflow: compare per-unit snapshotDigests with the latest
3055
+ // frozen plan. Identical public bytes → no publish path (all publish-class
3056
+ // external actions are dropped from the plan, so approve/publish execute
3057
+ // nothing). No comparable plan or any byte difference → publish-needed
3058
+ // (fail-safe). The decision is bound into the plan digest as
3059
+ // `workflowDecision`, making the trim immutable and auditable.
3060
+ let workflowDecision = null;
3061
+ if (workflow === 'config') {
3062
+ const previousPlan = await readLatestFrozenPlan(realRoot);
3063
+ let publishPath;
3064
+ let decision;
3065
+ if (!previousPlan) {
3066
+ publishPath = 'publish-needed';
3067
+ decision = 'indeterminable';
3068
+ } else {
3069
+ const prevDigests = new Map(
3070
+ (previousPlan.plan.units ?? []).map((u) => [u.id, u.snapshotDigest]),
3071
+ );
3072
+ const unchanged =
3073
+ units.length > 0 && units.every((u) => prevDigests.get(u.id) === u.snapshotDigest);
3074
+ publishPath = unchanged ? 'no-publish-needed' : 'publish-needed';
3075
+ decision = unchanged ? 'public-bytes-unchanged' : 'public-bytes-changed';
3076
+ }
3077
+ workflowDecision = {
3078
+ workflowKind: workflow,
3079
+ decision,
3080
+ publishPath,
3081
+ trimmedGates: [
3082
+ 'declared-hooks',
3083
+ 'snapshot-verify-gates',
3084
+ 'source-authority-closure',
3085
+ 'skill-resource-closure',
3086
+ ],
3087
+ ...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
3088
+ };
3089
+ if (publishPath === 'no-publish-needed') {
3090
+ externalActions = [];
3091
+ }
3092
+ await evidence.append({
3093
+ phase: 'workflow-decision',
3094
+ status: publishPath,
3095
+ decision,
3096
+ ...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
3097
+ actionCount: externalActions.length,
3098
+ });
3099
+ } else if (trimmedWorkflow) {
3100
+ workflowDecision = {
3101
+ workflowKind: workflow,
3102
+ decision: 'code-gates-trimmed',
3103
+ publishPath: 'publish-needed',
3104
+ trimmedGates: [
3105
+ 'declared-hooks',
3106
+ 'snapshot-verify-gates',
3107
+ 'source-authority-closure',
3108
+ 'skill-resource-closure',
3109
+ ],
3110
+ };
3111
+ await evidence.append({
3112
+ phase: 'workflow-decision',
3113
+ status: 'publish-needed',
3114
+ decision: 'code-gates-trimmed',
3115
+ });
3116
+ }
3117
+
2813
3118
  // Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
2814
3119
  // When present, new plans mark them as non-blocking manual follow-up tasks.
2815
3120
  const hasHumanConsumerActions = externalActions.some(
2816
3121
  (a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
2817
3122
  );
2818
3123
 
3124
+ // --- Fold the validated postPublish declaration into the frozen plan ---
3125
+ // Bindings: tag (tagTemplate rendered at the resolved target version —
3126
+ // the same computation create-tag will use), tagCommit (the frozen
3127
+ // production asset commit the tag will point at; only production
3128
+ // prepares can freeze it — distribute fails closed when it is absent),
3129
+ // unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
3130
+ // contract: payload may only come from the detached worktree at
3131
+ // tagCommit, never from workspace state). planVersion 2 record-layer
3132
+ // stripping does not strip this block, so every declaration detail is
3133
+ // bound into the plan digest.
3134
+ let frozenPostPublish = null;
3135
+ if (postPublishDeclaration) {
3136
+ const { unit, index } = postPublishDeclaration;
3137
+ const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
3138
+ frozenPostPublish = {
3139
+ ...structuredClone(unit.postPublish),
3140
+ tag,
3141
+ ...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
3142
+ unitId: unit.id,
3143
+ payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
3144
+ };
3145
+ }
3146
+
2819
3147
  const plan = {
2820
3148
  planVersion: 2,
2821
3149
  status: 'PREPARED',
3150
+ // Workflow profile (H5): 'full' for the complete gate set;
3151
+ // 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
3152
+ // fields are binding-layer (not stripped from the plan digest), so the
3153
+ // trim is frozen immutably with the plan.
3154
+ workflowKind: workflow,
3155
+ ...(workflowDecision ? { workflowDecision } : {}),
2822
3156
  baseline: {
2823
3157
  gitTreeHash: baseline.gitTreeHash,
2824
3158
  headCommit: baseline.gitHead,
@@ -2828,14 +3162,18 @@ export async function prepareRelease(options) {
2828
3162
  capturedAt: baseline.capturedAt,
2829
3163
  },
2830
3164
  configDigest,
2831
- skillResourceClosure: {
2832
- checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
2833
- unitReceipts: skillResourceClosureResults,
2834
- totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
2835
- totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
2836
- totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
2837
- totalFindingCount: 0,
2838
- },
3165
+ ...(skillResourceClosureResults.length > 0
3166
+ ? {
3167
+ skillResourceClosure: {
3168
+ checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
3169
+ unitReceipts: skillResourceClosureResults,
3170
+ totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
3171
+ totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
3172
+ totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
3173
+ totalFindingCount: 0,
3174
+ },
3175
+ }
3176
+ : {}),
2839
3177
  verificationGates: config.verificationGates ?? [],
2840
3178
  snapshotDigest: overallSnapshotDigest,
2841
3179
  ...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
@@ -2847,6 +3185,7 @@ export async function prepareRelease(options) {
2847
3185
  } : {}),
2848
3186
  units,
2849
3187
  externalActions,
3188
+ ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
2850
3189
  ...(sourceAuthority ? { sourceAuthority } : {}),
2851
3190
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
2852
3191
  };
@@ -2886,6 +3225,8 @@ export async function prepareRelease(options) {
2886
3225
  unitCount: units.length,
2887
3226
  actionCount: externalActions.length,
2888
3227
  offline,
3228
+ workflowKind: workflow,
3229
+ ...(workflowDecision ? { workflowDecision } : {}),
2889
3230
  completedAt: (clock ? clock() : new Date().toISOString()),
2890
3231
  });
2891
3232