release-skill 0.5.1 → 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 (94) 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 +13 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +14 -14
  10. package/README.zh-CN.md +15 -15
  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/release-skill.bundle.mjs +3226 -565
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  16. package/adapters/claude/schemas/release-project.schema.json +291 -0
  17. package/adapters/claude/schemas/release-run.schema.json +59 -6
  18. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  19. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  20. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  21. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  22. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  23. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/release-skill.bundle.mjs +3226 -565
  26. package/adapters/codex/schemas/.render-manifest.json +6 -6
  27. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  28. package/adapters/codex/schemas/release-project.schema.json +291 -0
  29. package/adapters/codex/schemas/release-run.schema.json +59 -6
  30. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  31. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  32. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  33. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  34. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  35. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/release-skill.bundle.mjs +3226 -565
  38. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  39. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  40. package/adapters/kimi/schemas/release-project.schema.json +291 -0
  41. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  42. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  43. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  44. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  45. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  46. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  47. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  48. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  49. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3226 -565
  50. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  51. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  52. package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
  53. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  54. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  55. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  56. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  57. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  58. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  60. package/bin/release-skill-cli.mjs +235 -7
  61. package/bin/release-skill.bundle.mjs +3226 -565
  62. package/package.json +3 -2
  63. package/platform-manifest.json +359 -0
  64. package/references/.render-manifest.json +9 -9
  65. package/references/02-project-config.md +35 -0
  66. package/references/05-evidence-and-errors.md +44 -0
  67. package/references/06-adapter-contract.md +13 -0
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +401 -2
  70. package/schemas/release-project.schema.json +291 -0
  71. package/schemas/release-run.schema.json +59 -6
  72. package/skills/release-config/SKILL.md +140 -0
  73. package/skills/release-docs/SKILL.md +106 -0
  74. package/skills/release-help/SKILL.md +39 -0
  75. package/skills/release-marketplace/SKILL.md +147 -0
  76. package/skills/release-publish/SKILL.md +25 -6
  77. package/skills/release-verify/SKILL.md +10 -0
  78. package/skills-src/release-config/SKILL.md +140 -0
  79. package/skills-src/release-docs/SKILL.md +106 -0
  80. package/skills-src/release-help/SKILL.md +39 -0
  81. package/skills-src/release-marketplace/SKILL.md +147 -0
  82. package/skills-src/release-publish/SKILL.md +25 -6
  83. package/skills-src/release-verify/SKILL.md +10 -0
  84. package/src/adapters/contract.mjs +5 -0
  85. package/src/adapters/distribute-git.mjs +625 -0
  86. package/src/commands/distribute.mjs +1078 -0
  87. package/src/commands/lineage.mjs +616 -0
  88. package/src/commands/prepare.mjs +335 -76
  89. package/src/commands/route.mjs +686 -0
  90. package/src/commands/ship.mjs +45 -1
  91. package/src/commands/verify.mjs +176 -0
  92. package/src/core/baseline-advance.mjs +185 -0
  93. package/src/core/checkpoints.mjs +25 -0
  94. 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
  // 安装契约常量
@@ -1693,6 +1694,43 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1693
1694
  *
1694
1695
  * @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
1695
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
+
1696
1734
  export async function prepareRelease(options) {
1697
1735
  const {
1698
1736
  root,
@@ -1704,9 +1742,31 @@ export async function prepareRelease(options) {
1704
1742
  hooksAuthorized,
1705
1743
  verificationGatesAuthorized,
1706
1744
  production = false,
1745
+ workflow = 'full',
1707
1746
  observePreviousPublicBaselineFn,
1708
1747
  } = options ?? {};
1709
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
+
1710
1770
  // --- Validate root ---
1711
1771
  if (!root || typeof root !== 'string') {
1712
1772
  throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
@@ -1785,6 +1845,28 @@ export async function prepareRelease(options) {
1785
1845
  });
1786
1846
  }
1787
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
+
1788
1870
  // --- Step 1b: Resolve authoritative versions and gate release-document
1789
1871
  // freshness BEFORE hook authorization ---
1790
1872
  // Authoritative versions resolve exactly once here and are reused by
@@ -1811,11 +1893,46 @@ export async function prepareRelease(options) {
1811
1893
  reasonTag: 'RELEASE_DOCS_STALE',
1812
1894
  });
1813
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
+
1814
1928
  // --- Step 2: Hook authorization gate ---
1815
1929
  // Hooks are user-configured arbitrary local processes without filesystem
1816
1930
  // or network isolation. They may write outside the project, access local
1817
1931
  // credentials, or make network calls. The user must explicitly accept
1818
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.
1819
1936
  const declaredHooks = Object.entries(config.hooks ?? {})
1820
1937
  .filter(([, hook]) => hook && hook.command)
1821
1938
  .map(([name, hook]) => ({
@@ -1829,7 +1946,7 @@ export async function prepareRelease(options) {
1829
1946
  // execution of configured commands. Old acknowledgement parameters
1830
1947
  // (--acknowledge-hook-side-effects, --acknowledge-gate-side-effects) are
1831
1948
  // accepted as no-effect compatibility inputs but are not required.
1832
- if (declaredHooks.length > 0) {
1949
+ if (!skipDeclaredHooks && declaredHooks.length > 0) {
1833
1950
  await evidence.append({
1834
1951
  phase: 'hook-authorization',
1835
1952
  status: 'authorized',
@@ -1857,16 +1974,24 @@ export async function prepareRelease(options) {
1857
1974
  }
1858
1975
 
1859
1976
  // --- Step 3: Run declared hooks ---
1860
- await evidence.append({ phase: 'hooks', status: 'started' });
1861
- await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1862
- hookCache: options.hookCache,
1863
- // Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
1864
- // reads envAllowlist keys exclusively from context.env, so the invoking
1865
- // shell's environment is injected here explicitly. Allowlist semantics
1866
- // are unchanged — only allowlisted keys from this map reach the child.
1867
- env: options.env ?? process.env,
1868
- });
1869
- 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
+ }
1870
1995
 
1871
1996
  // --- Step 3b: Re-check release-document freshness AFTER hooks ---
1872
1997
  // Declared hooks run as arbitrary local processes; they may rewrite a
@@ -1968,7 +2093,7 @@ export async function prepareRelease(options) {
1968
2093
 
1969
2094
  let sourceAuthority = null;
1970
2095
  let sourceInputClosure = null;
1971
- if (production) {
2096
+ if (production && !skipSourceAuthorityClosure) {
1972
2097
  if (!sourceRepository || typeof sourceRepository !== 'string') {
1973
2098
  throw new ReleaseError(
1974
2099
  CONFIG_MISSING,
@@ -2038,6 +2163,12 @@ export async function prepareRelease(options) {
2038
2163
  inputDigest: sourceInputClosure.digest,
2039
2164
  remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
2040
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
+ });
2041
2172
  }
2042
2173
 
2043
2174
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
@@ -2358,24 +2489,38 @@ export async function prepareRelease(options) {
2358
2489
  // Snapshot gates always run on disposable writable copies. The public
2359
2490
  // snapshot authority is re-digested after every gate and is never exposed
2360
2491
  // as the gate working directory.
2361
- const snapshotGateResults = await runSnapshotVerificationGates({
2362
- gates: declaredVerificationGates,
2363
- unitResults,
2364
- runDir,
2365
- evidence,
2366
- env: options.gateEnv ?? process.env,
2367
- });
2368
- await evidence.append({
2369
- phase: 'snapshot-verify',
2370
- status: 'completed',
2371
- gateCount: snapshotGateResults.length,
2372
- });
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
+ }
2373
2516
 
2374
2517
  // Bind the remote source-authority proof to the exact bytes that entered
2375
2518
  // the frozen snapshots, not merely to an earlier read of the workspace.
2376
2519
  // Then re-read the complete closure and dirty state once more so version
2377
2520
  // sources and non-snapshot closure entries cannot drift during prepare.
2378
- if (production) {
2521
+ // Trimmed together with Step 3c when a docs/config/marketplace workflow
2522
+ // skips the source-authority closure.
2523
+ if (production && !skipSourceAuthorityClosure) {
2379
2524
  const snapshotSourceResult = verifySnapshotSourcesMatchClosure({
2380
2525
  closure: sourceInputClosure,
2381
2526
  unitResults,
@@ -2502,59 +2647,72 @@ export async function prepareRelease(options) {
2502
2647
  // publish will re-verify, rather than the writable pre-freeze staging tree.
2503
2648
  // Non-production plans scan the final staging tree at the same point.
2504
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.
2505
2654
  const skillResourceClosureResults = [];
2506
- for (const { unit, manifest } of unitResults) {
2655
+ if (skipSkillResourceClosure) {
2507
2656
  await evidence.append({
2508
2657
  phase: 'skill-resource-closure',
2509
- status: 'started',
2510
- unitId: unit.id,
2658
+ status: 'skipped',
2659
+ reason: `workflow "${workflow}" trims the skill resource closure gate`,
2660
+ unitCount: unitResults.length,
2511
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
+ });
2512
2669
 
2513
- const closureResult = await checkSkillResourceClosure({
2514
- snapshotDir: manifest.outputDir,
2515
- host: 'root',
2516
- });
2670
+ const closureResult = await checkSkillResourceClosure({
2671
+ snapshotDir: manifest.outputDir,
2672
+ host: 'root',
2673
+ });
2517
2674
 
2518
- const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2519
- skillResourceClosureResults.push(receipt);
2675
+ const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2676
+ skillResourceClosureResults.push(receipt);
2677
+
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
+ }
2520
2702
 
2521
- if (closureResult.findings.length > 0) {
2522
2703
  await evidence.append({
2523
2704
  phase: 'skill-resource-closure',
2524
- status: 'blocking',
2705
+ status: 'completed',
2525
2706
  unitId: unit.id,
2526
- findingCount: closureResult.findings.length,
2527
- findings: closureResult.findings.map((f) => ({
2528
- skill: f.skill,
2529
- line: f.line,
2530
- reference: f.reference,
2531
- classification: f.classification,
2532
- code: f.code,
2533
- })),
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,
2534
2714
  });
2535
- throw new ReleaseError(
2536
- GATE_FAILED,
2537
- `skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
2538
- {
2539
- unitId: unit.id,
2540
- findingCount: closureResult.findings.length,
2541
- findings: closureResult.findings,
2542
- },
2543
- );
2544
2715
  }
2545
-
2546
- await evidence.append({
2547
- phase: 'skill-resource-closure',
2548
- status: 'completed',
2549
- unitId: unit.id,
2550
- checkerVersion: closureResult.checkerVersion,
2551
- surfaceCount: receipt.surfaceCount,
2552
- skillCount: receipt.skillCount,
2553
- referenceCount: closureResult.referenceCount,
2554
- sourceOnlyCount: closureResult.sourceOnlyCount,
2555
- findingCount: 0,
2556
- receiptDigest: closureResult.receiptDigest,
2557
- });
2558
2716
  }
2559
2717
 
2560
2718
  // Freeze external independent marketplace HEADs (production + online only):
@@ -2887,20 +3045,114 @@ export async function prepareRelease(options) {
2887
3045
  // 构建冻结分发映射:unitId -> frozen distributions
2888
3046
  const frozenDistributionsMap = new Map(units.map((u) => [u.id, u.distributions]));
2889
3047
 
2890
- const externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
3048
+ let externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
2891
3049
 
2892
3050
  // Compute overall snapshot digest
2893
3051
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
2894
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
+
2895
3118
  // Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
2896
3119
  // When present, new plans mark them as non-blocking manual follow-up tasks.
2897
3120
  const hasHumanConsumerActions = externalActions.some(
2898
3121
  (a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
2899
3122
  );
2900
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
+
2901
3147
  const plan = {
2902
3148
  planVersion: 2,
2903
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 } : {}),
2904
3156
  baseline: {
2905
3157
  gitTreeHash: baseline.gitTreeHash,
2906
3158
  headCommit: baseline.gitHead,
@@ -2910,14 +3162,18 @@ export async function prepareRelease(options) {
2910
3162
  capturedAt: baseline.capturedAt,
2911
3163
  },
2912
3164
  configDigest,
2913
- skillResourceClosure: {
2914
- checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
2915
- unitReceipts: skillResourceClosureResults,
2916
- totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
2917
- totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
2918
- totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
2919
- totalFindingCount: 0,
2920
- },
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
+ : {}),
2921
3177
  verificationGates: config.verificationGates ?? [],
2922
3178
  snapshotDigest: overallSnapshotDigest,
2923
3179
  ...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
@@ -2929,6 +3185,7 @@ export async function prepareRelease(options) {
2929
3185
  } : {}),
2930
3186
  units,
2931
3187
  externalActions,
3188
+ ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
2932
3189
  ...(sourceAuthority ? { sourceAuthority } : {}),
2933
3190
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
2934
3191
  };
@@ -2968,6 +3225,8 @@ export async function prepareRelease(options) {
2968
3225
  unitCount: units.length,
2969
3226
  actionCount: externalActions.length,
2970
3227
  offline,
3228
+ workflowKind: workflow,
3229
+ ...(workflowDecision ? { workflowDecision } : {}),
2971
3230
  completedAt: (clock ? clock() : new Date().toISOString()),
2972
3231
  });
2973
3232