release-skill 0.5.1 → 0.6.1

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 (100) 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 +27 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +15 -14
  10. package/README.zh-CN.md +16 -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 +3779 -769
  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 +299 -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 +3779 -769
  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 +299 -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 +3779 -769
  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 +299 -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 +3779 -769
  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 +299 -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 +246 -7
  61. package/bin/release-skill.bundle.mjs +3779 -769
  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 +45 -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 +299 -0
  71. package/schemas/release-run.schema.json +59 -6
  72. package/scripts/build-bundle.mjs +11 -2
  73. package/scripts/sync-public-files.mjs +4 -0
  74. package/skills/release-config/SKILL.md +140 -0
  75. package/skills/release-docs/SKILL.md +106 -0
  76. package/skills/release-help/SKILL.md +39 -0
  77. package/skills/release-marketplace/SKILL.md +147 -0
  78. package/skills/release-publish/SKILL.md +25 -6
  79. package/skills/release-verify/SKILL.md +10 -0
  80. package/skills-src/release-config/SKILL.md +140 -0
  81. package/skills-src/release-docs/SKILL.md +106 -0
  82. package/skills-src/release-help/SKILL.md +39 -0
  83. package/skills-src/release-marketplace/SKILL.md +147 -0
  84. package/skills-src/release-publish/SKILL.md +25 -6
  85. package/skills-src/release-verify/SKILL.md +10 -0
  86. package/src/adapters/contract.mjs +5 -0
  87. package/src/adapters/distribute-git.mjs +625 -0
  88. package/src/commands/distribute.mjs +1078 -0
  89. package/src/commands/lineage.mjs +616 -0
  90. package/src/commands/prepare.mjs +548 -83
  91. package/src/commands/route.mjs +686 -0
  92. package/src/commands/ship.mjs +45 -1
  93. package/src/commands/verify.mjs +198 -0
  94. package/src/core/baseline-advance.mjs +185 -0
  95. package/src/core/bundle-freshness.mjs +236 -0
  96. package/src/core/checkpoints.mjs +25 -0
  97. package/src/core/errors.mjs +2 -0
  98. package/src/core/frozen-marker.mjs +97 -0
  99. package/src/core/hooks.mjs +12 -1
  100. package/src/core/postpublish.mjs +315 -0
@@ -18,8 +18,8 @@
18
18
  * @module commands/prepare
19
19
  */
20
20
 
21
- import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
22
- import { readFile, mkdir, realpath } from 'node:fs/promises';
21
+ import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
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
 
@@ -53,7 +53,10 @@ import {
53
53
  normalizeGitTimestamp,
54
54
  sealFrozenSnapshot,
55
55
  } from '../snapshot/frozen.mjs';
56
- import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT } from '../core/errors.mjs';
56
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
57
+ import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
58
+ import { PKG_ROOT } from '../core/pkg-root.mjs';
59
+ import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
57
60
  import {
58
61
  SOURCE_INPUT_ALGORITHM_VERSION,
59
62
  computeSourceInputClosure,
@@ -67,6 +70,7 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
67
70
  import { PLATFORMS } from '../platforms/registry.mjs';
68
71
  import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
69
72
  import { buildInstallationContract, computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from '../core/installation-contract.mjs';
73
+ import { validatePostPublishDeclaration, PAYLOAD_SOURCE_TAG_WORKTREE } from '../core/postpublish.mjs';
70
74
 
71
75
  // ---------------------------------------------------------------------------
72
76
  // 安装契约常量
@@ -223,6 +227,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
223
227
  // Hooks execution
224
228
  // ---------------------------------------------------------------------------
225
229
 
230
+ /** Maximum number of output lines preserved in a hook-failure tail. */
231
+ const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
232
+ /** Maximum bytes preserved in a hook-failure tail. */
233
+ const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
234
+
235
+ /**
236
+ * Bound a captured child-output stream to the tail that matters for triage:
237
+ * the last 50 lines, further capped at 8 KB — whichever is smaller.
238
+ *
239
+ * @param {string} [text] - Captured stdout/stderr text.
240
+ * @returns {string} The bounded tail ('' for empty/absent input).
241
+ */
242
+ export function boundedOutputTail(text) {
243
+ if (typeof text !== 'string' || text.length === 0) return '';
244
+ let lines = text.split('\n');
245
+ // A trailing newline produces an empty final element; drop it so the line
246
+ // budget counts real output lines.
247
+ if (lines.length > 1 && lines[lines.length - 1] === '') {
248
+ lines = lines.slice(0, -1);
249
+ }
250
+ let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
251
+ let joined = tail.join('\n');
252
+ while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
253
+ tail = tail.slice(1);
254
+ joined = tail.join('\n');
255
+ }
256
+ if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
257
+ // A single line exceeds the byte cap: keep the trailing bytes.
258
+ const buf = Buffer.from(joined, 'utf8');
259
+ joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
260
+ }
261
+ return joined;
262
+ }
263
+
226
264
  /**
227
265
  * Run all declared project hooks in order: docs, build, test, typecheck.
228
266
  *
@@ -235,6 +273,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
235
273
  * Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
236
274
  * glob that matches nothing fails closed before the hook runs.
237
275
  *
276
+ * Failure output passthrough (2026-08-18 investigation §4.1): the executor
277
+ * already captures child stdout/stderr on non-zero exit; on failure this
278
+ * layer writes bounded tails into the hooks evidence event AND echoes them to
279
+ * the current process' stderr, so a failing hook is diagnosable on the
280
+ * terminal without opening evidence.jsonl. Success events carry no tails.
281
+ * Exit-code semantics are unchanged.
282
+ *
238
283
  * @param {object} config - The loaded project config.
239
284
  * @param {string} root - Absolute project root.
240
285
  * @param {object} evidence - The evidence writer.
@@ -250,7 +295,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
250
295
  * injectable. Defaults to process.env at the prepare call site, which makes
251
296
  * allowlisted keys exported by the invoking shell reach the hook
252
297
  * subprocess.
253
- * @returns {Promise<void>}
298
+ * @returns {Promise<Array<{ name: string, completed: boolean, cached: boolean, testSelection: string | undefined }>>}
299
+ * One record per declared hook that completed (fresh or cached replay).
300
+ * Failures throw instead of returning a record.
254
301
  * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
255
302
  * throws, or declares a cacheInputs glob that matches no file.
256
303
  */
@@ -258,15 +305,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
258
305
  const hookOrder = ['docs', 'build', 'test', 'typecheck'];
259
306
  const hooks = config.hooks ?? {};
260
307
  const cacheEnabled = options.hookCache !== false;
308
+ const records = [];
261
309
 
262
310
  for (const name of hookOrder) {
263
311
  const hook = hooks[name];
264
312
  if (!hook) continue;
265
313
 
314
+ // Test-selection evidence (2026-08-18 investigation §4.4): the test hook
315
+ // records whether it ran the full suite. Absence of a declaration means
316
+ // 'full' — every existing config stays backward compatible.
317
+ const selectionField = name === 'test'
318
+ ? { testSelection: hook.testSelection === 'incremental' ? 'incremental' : 'full' }
319
+ : {};
320
+
266
321
  await evidence.append({
267
322
  phase: 'hooks',
268
323
  status: 'started',
269
324
  hookName: name,
325
+ ...selectionField,
270
326
  });
271
327
 
272
328
  // --- Incremental cache lookup (opt-in only; default zero change) ---
@@ -294,7 +350,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
294
350
  hookName: name,
295
351
  cached: true,
296
352
  cacheKey,
353
+ ...selectionField,
297
354
  });
355
+ records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
298
356
  continue;
299
357
  }
300
358
  }
@@ -311,6 +369,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
311
369
  status: 'failed',
312
370
  hookName: name,
313
371
  error: { code: err.code, message: err.message },
372
+ ...selectionField,
314
373
  });
315
374
  throw new ReleaseError(
316
375
  GATE_FAILED,
@@ -320,16 +379,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
320
379
  }
321
380
 
322
381
  if (result.exitCode !== 0) {
382
+ const stdoutTail = boundedOutputTail(result.stdout);
383
+ const stderrTail = boundedOutputTail(result.stderr);
384
+ // Echo the captured tails to the current process' stderr so a failing
385
+ // hook is diagnosable on the terminal without opening evidence.jsonl
386
+ // (2026-08-18 investigation §4.1). Exit-code semantics are untouched.
387
+ process.stderr.write(`[release-skill] hook "${name}" failed with exit code ${result.exitCode}\n`);
388
+ if (stdoutTail) {
389
+ process.stderr.write(`[release-skill] hook "${name}" stdout tail:\n${stdoutTail}\n`);
390
+ }
391
+ if (stderrTail) {
392
+ process.stderr.write(`[release-skill] hook "${name}" stderr tail:\n${stderrTail}\n`);
393
+ }
323
394
  await evidence.append({
324
395
  phase: 'hooks',
325
396
  status: 'failed',
326
397
  hookName: name,
327
398
  exitCode: result.exitCode,
328
399
  // Test runners usually emit the actionable failure summary at the
329
- // end. Preserve bounded tails of both streams instead of the noisy
330
- // compiler prelude at the beginning.
331
- stdoutTail: result.stdout.slice(-4000),
332
- stderrTail: result.stderr.slice(-4000),
400
+ // end. Preserve bounded tails (last 50 lines, capped at 8 KB) of both
401
+ // streams instead of the noisy compiler prelude at the beginning.
402
+ stdoutTail,
403
+ stderrTail,
404
+ ...selectionField,
333
405
  });
334
406
  throw new ReleaseError(
335
407
  GATE_FAILED,
@@ -363,8 +435,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
363
435
  status: 'completed',
364
436
  hookName: name,
365
437
  exitCode: 0,
438
+ ...selectionField,
366
439
  });
440
+ records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
367
441
  }
442
+
443
+ return records;
368
444
  }
369
445
 
370
446
  // ---------------------------------------------------------------------------
@@ -1693,6 +1769,43 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1693
1769
  *
1694
1770
  * @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
1695
1771
  */
1772
+
1773
+ /**
1774
+ * Read the newest frozen plan from `.release-skill/plans/` (digest-addressed
1775
+ * immutable plans written by writePlanImmutable). Used by the config workflow
1776
+ * decision to compare public bytes (per-unit snapshotDigest) against the
1777
+ * latest frozen state. Returns null when no readable plan exists.
1778
+ *
1779
+ * @param {string} root - project root directory
1780
+ * @returns {Promise<{ plan: object, fileName: string } | null>}
1781
+ */
1782
+ export async function readLatestFrozenPlan(root) {
1783
+ const plansDir = resolve(root, '.release-skill', 'plans');
1784
+ let files;
1785
+ try {
1786
+ files = await readdir(plansDir);
1787
+ } catch {
1788
+ return null;
1789
+ }
1790
+ let best = null;
1791
+ for (const file of files.sort()) {
1792
+ if (!file.endsWith('.json')) continue;
1793
+ let plan;
1794
+ try {
1795
+ plan = JSON.parse(await readFile(resolve(plansDir, file), 'utf8'));
1796
+ } catch {
1797
+ continue;
1798
+ }
1799
+ if (!plan || typeof plan !== 'object' || !Array.isArray(plan.units)) continue;
1800
+ const ts = plan.createdAt ? new Date(plan.createdAt).getTime() : NaN;
1801
+ const effectiveTs = Number.isFinite(ts) ? ts : 0;
1802
+ if (!best || effectiveTs > best.ts) {
1803
+ best = { plan, fileName: file, ts: effectiveTs };
1804
+ }
1805
+ }
1806
+ return best ? { plan: best.plan, fileName: best.fileName } : null;
1807
+ }
1808
+
1696
1809
  export async function prepareRelease(options) {
1697
1810
  const {
1698
1811
  root,
@@ -1704,9 +1817,50 @@ export async function prepareRelease(options) {
1704
1817
  hooksAuthorized,
1705
1818
  verificationGatesAuthorized,
1706
1819
  production = false,
1820
+ workflow = 'full',
1707
1821
  observePreviousPublicBaselineFn,
1822
+ testSelection = 'full',
1708
1823
  } = options ?? {};
1709
1824
 
1825
+ // --- Workflow profile (H5) ---
1826
+ // 'full' runs the complete gate set. 'docs', 'config' and 'marketplace'
1827
+ // trim only code-class gates — declared hooks, snapshot-verify gates,
1828
+ // source-authority closure, and skill-resource-closure — and record the
1829
+ // trim as `workflowDecision` bound into the plan digest. All other gates
1830
+ // (docs freshness, public surface, baseline, snapshots, remote checks,
1831
+ // plan freeze, consumer-verify gates) run identically for every workflow.
1832
+ const WORKFLOW_KINDS = new Set(['full', 'docs', 'config', 'marketplace']);
1833
+ if (!WORKFLOW_KINDS.has(workflow)) {
1834
+ throw new ReleaseError(
1835
+ CONFIG_INVALID,
1836
+ `unknown workflow kind "${workflow}"; expected one of ${[...WORKFLOW_KINDS].sort().join(', ')}`,
1837
+ { workflow },
1838
+ );
1839
+ }
1840
+
1841
+ // --- Test selection (2026-08-18 investigation §4.4, review §3.4) ---
1842
+ // Design decision: prepare IS the freeze, so incremental test selection is
1843
+ // rejected outright; the flag is reserved for a future preflight mode.
1844
+ if (testSelection === 'incremental') {
1845
+ throw new ReleaseError(
1846
+ GATE_FAILED,
1847
+ 'incremental selection is not allowed at freeze time',
1848
+ { testSelection, reservedFor: 'preflight mode' },
1849
+ );
1850
+ }
1851
+ if (testSelection !== 'full') {
1852
+ throw new ReleaseError(
1853
+ CONFIG_INVALID,
1854
+ `unknown testSelection "${testSelection}"; expected "full" or "incremental"`,
1855
+ { testSelection },
1856
+ );
1857
+ }
1858
+ const trimmedWorkflow = workflow !== 'full';
1859
+ const skipDeclaredHooks = trimmedWorkflow;
1860
+ const skipSnapshotVerifyGates = trimmedWorkflow;
1861
+ const skipSourceAuthorityClosure = trimmedWorkflow;
1862
+ const skipSkillResourceClosure = trimmedWorkflow;
1863
+
1710
1864
  // --- Validate root ---
1711
1865
  if (!root || typeof root !== 'string') {
1712
1866
  throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
@@ -1785,6 +1939,66 @@ export async function prepareRelease(options) {
1785
1939
  });
1786
1940
  }
1787
1941
 
1942
+ // --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
1943
+ // 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
1944
+ // used to surface only deep inside test hooks. Compare the deterministic
1945
+ // source digest embedded in the bundle at build time with the current
1946
+ // sources at the earliest stage — config loaded, before any hook.
1947
+ // This gate is artifact-integrity class, NOT a code-class gate:
1948
+ // docs/config/marketplace workflow trimming must never exempt it.
1949
+ await evidence.append({ phase: 'bundle-freshness', status: 'started' });
1950
+ const bundleFreshnessFn = options.bundleFreshnessFn ?? assertBundleFreshness;
1951
+ let bundleFreshness;
1952
+ try {
1953
+ bundleFreshness = await bundleFreshnessFn(PKG_ROOT);
1954
+ } catch (err) {
1955
+ await evidence.append({
1956
+ phase: 'bundle-freshness',
1957
+ status: 'blocking',
1958
+ reason: err.details?.reason ?? null,
1959
+ error: { code: err.code, message: err.message },
1960
+ });
1961
+ throw err;
1962
+ }
1963
+ if (bundleFreshness?.applicable === false) {
1964
+ // Installed distributions ship no mutable src/ next to the bundle;
1965
+ // staleness is a source-checkout concern only.
1966
+ await evidence.append({
1967
+ phase: 'bundle-freshness',
1968
+ status: 'not-applicable',
1969
+ reason: bundleFreshness.reason,
1970
+ });
1971
+ } else {
1972
+ await evidence.append({
1973
+ phase: 'bundle-freshness',
1974
+ status: 'completed',
1975
+ algorithm: bundleFreshness?.algorithm ?? null,
1976
+ sourceDigest: bundleFreshness?.sourceDigest ?? null,
1977
+ });
1978
+ }
1979
+
1980
+ // --- Step 1a: Workflow configuration evidence ---
1981
+ // Records the deterministic trim set for docs/config/marketplace
1982
+ // workflows. The trim never weakens the retained gates; it only removes
1983
+ // code-class gates that a non-code change surface cannot exercise.
1984
+ await evidence.append({
1985
+ phase: 'workflow',
1986
+ status: 'configured',
1987
+ workflowKind: workflow,
1988
+ trimmedGates: trimmedWorkflow
1989
+ ? ['declared-hooks', 'snapshot-verify-gates', 'source-authority-closure', 'skill-resource-closure']
1990
+ : [],
1991
+ retainedGates: [
1992
+ 'docs-freshness',
1993
+ 'public-surface',
1994
+ 'baseline',
1995
+ 'snapshots',
1996
+ 'remote-check',
1997
+ 'plan-freeze',
1998
+ 'consumer-verify',
1999
+ ],
2000
+ });
2001
+
1788
2002
  // --- Step 1b: Resolve authoritative versions and gate release-document
1789
2003
  // freshness BEFORE hook authorization ---
1790
2004
  // Authoritative versions resolve exactly once here and are reused by
@@ -1811,11 +2025,46 @@ export async function prepareRelease(options) {
1811
2025
  reasonTag: 'RELEASE_DOCS_STALE',
1812
2026
  });
1813
2027
 
2028
+ // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
2029
+ // The per-unit postPublish block drives the post-publish distribute
2030
+ // command. Validate it here, before any hook, baseline, snapshot, remote
2031
+ // check, or plan write, so an unsafe declaration fails closed with zero
2032
+ // side effects. This is the runtime re-check on top of the config JSON
2033
+ // schema: plans frozen by older schema versions must not be able to
2034
+ // smuggle shell strings, option-like executables, or secret-ish env
2035
+ // keys through. A plan binds exactly one declaration; multiple units
2036
+ // declaring postPublish is a hard gate failure.
2037
+ const postPublishDeclarations = configUnits
2038
+ .map((unit, index) => ({ unit, index }))
2039
+ .filter(({ unit }) => unit.postPublish !== undefined);
2040
+ if (postPublishDeclarations.length > 1) {
2041
+ throw new ReleaseError(
2042
+ GATE_FAILED,
2043
+ `multiple units declare postPublish (${postPublishDeclarations.map(({ unit }) => unit.id).join(', ')}); a release plan binds exactly one postPublish declaration`,
2044
+ { unitIds: postPublishDeclarations.map(({ unit }) => unit.id) },
2045
+ );
2046
+ }
2047
+ let postPublishDeclaration = null;
2048
+ if (postPublishDeclarations.length === 1) {
2049
+ const { unit, index } = postPublishDeclarations[0];
2050
+ validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
2051
+ postPublishDeclaration = { unit, index };
2052
+ await evidence.append({
2053
+ phase: 'postpublish-declaration',
2054
+ status: 'validated',
2055
+ unitId: unit.id,
2056
+ targetCount: unit.postPublish.targets.length,
2057
+ });
2058
+ }
2059
+
1814
2060
  // --- Step 2: Hook authorization gate ---
1815
2061
  // Hooks are user-configured arbitrary local processes without filesystem
1816
2062
  // or network isolation. They may write outside the project, access local
1817
2063
  // credentials, or make network calls. The user must explicitly accept
1818
2064
  // these risks before any hook is executed.
2065
+ // docs/config/marketplace workflows trim code-class hooks entirely
2066
+ // (H5): a non-code change surface cannot exercise them, so they are
2067
+ // recorded as skipped rather than authorized-and-run.
1819
2068
  const declaredHooks = Object.entries(config.hooks ?? {})
1820
2069
  .filter(([, hook]) => hook && hook.command)
1821
2070
  .map(([name, hook]) => ({
@@ -1829,7 +2078,7 @@ export async function prepareRelease(options) {
1829
2078
  // execution of configured commands. Old acknowledgement parameters
1830
2079
  // (--acknowledge-hook-side-effects, --acknowledge-gate-side-effects) are
1831
2080
  // accepted as no-effect compatibility inputs but are not required.
1832
- if (declaredHooks.length > 0) {
2081
+ if (!skipDeclaredHooks && declaredHooks.length > 0) {
1833
2082
  await evidence.append({
1834
2083
  phase: 'hook-authorization',
1835
2084
  status: 'authorized',
@@ -1857,16 +2106,25 @@ export async function prepareRelease(options) {
1857
2106
  }
1858
2107
 
1859
2108
  // --- 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' });
2109
+ let hookRecords = [];
2110
+ if (skipDeclaredHooks) {
2111
+ await evidence.append({
2112
+ phase: 'hooks',
2113
+ status: 'skipped',
2114
+ reason: `workflow "${workflow}" trims code-class hooks; declared hooks: ${declaredHooks.length}`,
2115
+ });
2116
+ } else {
2117
+ await evidence.append({ phase: 'hooks', status: 'started' });
2118
+ hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
2119
+ hookCache: options.hookCache,
2120
+ // Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
2121
+ // reads envAllowlist keys exclusively from context.env, so the invoking
2122
+ // shell's environment is injected here explicitly. Allowlist semantics
2123
+ // are unchanged — only allowlisted keys from this map reach the child.
2124
+ env: options.env ?? process.env,
2125
+ });
2126
+ await evidence.append({ phase: 'hooks', status: 'completed' });
2127
+ }
1870
2128
 
1871
2129
  // --- Step 3b: Re-check release-document freshness AFTER hooks ---
1872
2130
  // Declared hooks run as arbitrary local processes; they may rewrite a
@@ -1968,7 +2226,7 @@ export async function prepareRelease(options) {
1968
2226
 
1969
2227
  let sourceAuthority = null;
1970
2228
  let sourceInputClosure = null;
1971
- if (production) {
2229
+ if (production && !skipSourceAuthorityClosure) {
1972
2230
  if (!sourceRepository || typeof sourceRepository !== 'string') {
1973
2231
  throw new ReleaseError(
1974
2232
  CONFIG_MISSING,
@@ -2038,6 +2296,12 @@ export async function prepareRelease(options) {
2038
2296
  inputDigest: sourceInputClosure.digest,
2039
2297
  remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
2040
2298
  });
2299
+ } else if (production) {
2300
+ await evidence.append({
2301
+ phase: 'source-authority',
2302
+ status: 'skipped',
2303
+ reason: `workflow "${workflow}" trims the source-authority content closure gate`,
2304
+ });
2041
2305
  }
2042
2306
 
2043
2307
  // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
@@ -2358,24 +2622,38 @@ export async function prepareRelease(options) {
2358
2622
  // Snapshot gates always run on disposable writable copies. The public
2359
2623
  // snapshot authority is re-digested after every gate and is never exposed
2360
2624
  // 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
- });
2625
+ // docs/config/marketplace workflows trim snapshot-verify gates (H5):
2626
+ // they are declared code-class gates that a non-code change surface
2627
+ // cannot exercise. Consumer-verify gates (phase 'consumer-verify') are
2628
+ // NOT part of this skip — they run during verify regardless of workflow.
2629
+ if (skipSnapshotVerifyGates) {
2630
+ await evidence.append({
2631
+ phase: 'snapshot-verify',
2632
+ status: 'skipped',
2633
+ reason: `workflow "${workflow}" trims snapshot-verify gates; declared gates: ${declaredVerificationGates.length}`,
2634
+ });
2635
+ } else {
2636
+ const snapshotGateResults = await runSnapshotVerificationGates({
2637
+ gates: declaredVerificationGates,
2638
+ unitResults,
2639
+ runDir,
2640
+ evidence,
2641
+ env: options.gateEnv ?? process.env,
2642
+ });
2643
+ await evidence.append({
2644
+ phase: 'snapshot-verify',
2645
+ status: 'completed',
2646
+ gateCount: snapshotGateResults.length,
2647
+ });
2648
+ }
2373
2649
 
2374
2650
  // Bind the remote source-authority proof to the exact bytes that entered
2375
2651
  // the frozen snapshots, not merely to an earlier read of the workspace.
2376
2652
  // Then re-read the complete closure and dirty state once more so version
2377
2653
  // sources and non-snapshot closure entries cannot drift during prepare.
2378
- if (production) {
2654
+ // Trimmed together with Step 3c when a docs/config/marketplace workflow
2655
+ // skips the source-authority closure.
2656
+ if (production && !skipSourceAuthorityClosure) {
2379
2657
  const snapshotSourceResult = verifySnapshotSourcesMatchClosure({
2380
2658
  closure: sourceInputClosure,
2381
2659
  unitResults,
@@ -2463,6 +2741,59 @@ export async function prepareRelease(options) {
2463
2741
  // --- Step 7: Build plan object ---
2464
2742
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
2465
2743
 
2744
+ // --- Step 7-gate: Full-test freeze gate (hard gate) ---
2745
+ // 2026-08-18 investigation §4.4 / review §3.4: "full test suite before
2746
+ // freeze" is a HARD gate, not a convention. The plan digest may only be
2747
+ // computed after a completed FULL-mode test hook exists in THIS run's
2748
+ // evidence (fresh run or a cached replay of a successful full run).
2749
+ // Built-in and read-only — like secret-scan, plan-digest binding, and
2750
+ // approval, it cannot be disabled by project overlays. Workflows that
2751
+ // trim declared hooks record the trim; projects declaring no test hook
2752
+ // pass vacuously (nothing can run incrementally there).
2753
+ if (skipDeclaredHooks) {
2754
+ await evidence.append({
2755
+ phase: 'full-test-gate',
2756
+ status: 'skipped',
2757
+ reason: `workflow "${workflow}" trims declared hooks`,
2758
+ });
2759
+ } else if (!config.hooks?.test) {
2760
+ await evidence.append({
2761
+ phase: 'full-test-gate',
2762
+ status: 'not-declared',
2763
+ reason: 'no test hook declared; nothing can run incrementally',
2764
+ });
2765
+ } else {
2766
+ const testRecord = hookRecords.find((record) => record.name === 'test');
2767
+ const satisfied = Boolean(
2768
+ testRecord && testRecord.completed && testRecord.testSelection === 'full',
2769
+ );
2770
+ if (!satisfied) {
2771
+ await evidence.append({
2772
+ phase: 'full-test-gate',
2773
+ status: 'blocking',
2774
+ testSelection: testRecord?.testSelection ?? null,
2775
+ cached: Boolean(testRecord?.cached),
2776
+ });
2777
+ throw new ReleaseError(
2778
+ GATE_FAILED,
2779
+ testRecord?.testSelection === 'incremental'
2780
+ ? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
2781
+ : 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
2782
+ {
2783
+ gate: 'full-test-freeze',
2784
+ testSelection: testRecord?.testSelection ?? null,
2785
+ cached: Boolean(testRecord?.cached),
2786
+ },
2787
+ );
2788
+ }
2789
+ await evidence.append({
2790
+ phase: 'full-test-gate',
2791
+ status: 'completed',
2792
+ testSelection: 'full',
2793
+ cached: Boolean(testRecord.cached),
2794
+ });
2795
+ }
2796
+
2466
2797
  // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
2467
2798
  // §4.2/§7). Production freeze timestamps are derived deterministically
2468
2799
  // from the baseline headCommit's committer date, before the first frozen
@@ -2502,59 +2833,72 @@ export async function prepareRelease(options) {
2502
2833
  // publish will re-verify, rather than the writable pre-freeze staging tree.
2503
2834
  // Non-production plans scan the final staging tree at the same point.
2504
2835
  // This gate is built in, read-only, and cannot be disabled by overlays.
2836
+ // docs/config/marketplace workflows trim it (H5): the plan omits
2837
+ // `skillResourceClosure` entirely, so publish's conditional re-check
2838
+ // (guarded by plan.skillResourceClosure) and verify's receipt comparison
2839
+ // both skip it consistently. The trim is recorded in workflowDecision.
2505
2840
  const skillResourceClosureResults = [];
2506
- for (const { unit, manifest } of unitResults) {
2841
+ if (skipSkillResourceClosure) {
2507
2842
  await evidence.append({
2508
2843
  phase: 'skill-resource-closure',
2509
- status: 'started',
2510
- unitId: unit.id,
2844
+ status: 'skipped',
2845
+ reason: `workflow "${workflow}" trims the skill resource closure gate`,
2846
+ unitCount: unitResults.length,
2511
2847
  });
2848
+ } else {
2849
+ for (const { unit, manifest } of unitResults) {
2850
+ await evidence.append({
2851
+ phase: 'skill-resource-closure',
2852
+ status: 'started',
2853
+ unitId: unit.id,
2854
+ });
2512
2855
 
2513
- const closureResult = await checkSkillResourceClosure({
2514
- snapshotDir: manifest.outputDir,
2515
- host: 'root',
2516
- });
2856
+ const closureResult = await checkSkillResourceClosure({
2857
+ snapshotDir: manifest.outputDir,
2858
+ host: 'root',
2859
+ });
2517
2860
 
2518
- const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2519
- skillResourceClosureResults.push(receipt);
2861
+ const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2862
+ skillResourceClosureResults.push(receipt);
2863
+
2864
+ if (closureResult.findings.length > 0) {
2865
+ await evidence.append({
2866
+ phase: 'skill-resource-closure',
2867
+ status: 'blocking',
2868
+ unitId: unit.id,
2869
+ findingCount: closureResult.findings.length,
2870
+ findings: closureResult.findings.map((f) => ({
2871
+ skill: f.skill,
2872
+ line: f.line,
2873
+ reference: f.reference,
2874
+ classification: f.classification,
2875
+ code: f.code,
2876
+ })),
2877
+ });
2878
+ throw new ReleaseError(
2879
+ GATE_FAILED,
2880
+ `skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
2881
+ {
2882
+ unitId: unit.id,
2883
+ findingCount: closureResult.findings.length,
2884
+ findings: closureResult.findings,
2885
+ },
2886
+ );
2887
+ }
2520
2888
 
2521
- if (closureResult.findings.length > 0) {
2522
2889
  await evidence.append({
2523
2890
  phase: 'skill-resource-closure',
2524
- status: 'blocking',
2891
+ status: 'completed',
2525
2892
  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
- })),
2893
+ checkerVersion: closureResult.checkerVersion,
2894
+ surfaceCount: receipt.surfaceCount,
2895
+ skillCount: receipt.skillCount,
2896
+ referenceCount: closureResult.referenceCount,
2897
+ sourceOnlyCount: closureResult.sourceOnlyCount,
2898
+ findingCount: 0,
2899
+ receiptDigest: closureResult.receiptDigest,
2534
2900
  });
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
2901
  }
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
2902
  }
2559
2903
 
2560
2904
  // Freeze external independent marketplace HEADs (production + online only):
@@ -2887,20 +3231,114 @@ export async function prepareRelease(options) {
2887
3231
  // 构建冻结分发映射:unitId -> frozen distributions
2888
3232
  const frozenDistributionsMap = new Map(units.map((u) => [u.id, u.distributions]));
2889
3233
 
2890
- const externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
3234
+ let externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
2891
3235
 
2892
3236
  // Compute overall snapshot digest
2893
3237
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
2894
3238
 
3239
+ // --- Step 6b: Workflow decision (H5) ---
3240
+ // config workflow: compare per-unit snapshotDigests with the latest
3241
+ // frozen plan. Identical public bytes → no publish path (all publish-class
3242
+ // external actions are dropped from the plan, so approve/publish execute
3243
+ // nothing). No comparable plan or any byte difference → publish-needed
3244
+ // (fail-safe). The decision is bound into the plan digest as
3245
+ // `workflowDecision`, making the trim immutable and auditable.
3246
+ let workflowDecision = null;
3247
+ if (workflow === 'config') {
3248
+ const previousPlan = await readLatestFrozenPlan(realRoot);
3249
+ let publishPath;
3250
+ let decision;
3251
+ if (!previousPlan) {
3252
+ publishPath = 'publish-needed';
3253
+ decision = 'indeterminable';
3254
+ } else {
3255
+ const prevDigests = new Map(
3256
+ (previousPlan.plan.units ?? []).map((u) => [u.id, u.snapshotDigest]),
3257
+ );
3258
+ const unchanged =
3259
+ units.length > 0 && units.every((u) => prevDigests.get(u.id) === u.snapshotDigest);
3260
+ publishPath = unchanged ? 'no-publish-needed' : 'publish-needed';
3261
+ decision = unchanged ? 'public-bytes-unchanged' : 'public-bytes-changed';
3262
+ }
3263
+ workflowDecision = {
3264
+ workflowKind: workflow,
3265
+ decision,
3266
+ publishPath,
3267
+ trimmedGates: [
3268
+ 'declared-hooks',
3269
+ 'snapshot-verify-gates',
3270
+ 'source-authority-closure',
3271
+ 'skill-resource-closure',
3272
+ ],
3273
+ ...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
3274
+ };
3275
+ if (publishPath === 'no-publish-needed') {
3276
+ externalActions = [];
3277
+ }
3278
+ await evidence.append({
3279
+ phase: 'workflow-decision',
3280
+ status: publishPath,
3281
+ decision,
3282
+ ...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
3283
+ actionCount: externalActions.length,
3284
+ });
3285
+ } else if (trimmedWorkflow) {
3286
+ workflowDecision = {
3287
+ workflowKind: workflow,
3288
+ decision: 'code-gates-trimmed',
3289
+ publishPath: 'publish-needed',
3290
+ trimmedGates: [
3291
+ 'declared-hooks',
3292
+ 'snapshot-verify-gates',
3293
+ 'source-authority-closure',
3294
+ 'skill-resource-closure',
3295
+ ],
3296
+ };
3297
+ await evidence.append({
3298
+ phase: 'workflow-decision',
3299
+ status: 'publish-needed',
3300
+ decision: 'code-gates-trimmed',
3301
+ });
3302
+ }
3303
+
2895
3304
  // Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
2896
3305
  // When present, new plans mark them as non-blocking manual follow-up tasks.
2897
3306
  const hasHumanConsumerActions = externalActions.some(
2898
3307
  (a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
2899
3308
  );
2900
3309
 
3310
+ // --- Fold the validated postPublish declaration into the frozen plan ---
3311
+ // Bindings: tag (tagTemplate rendered at the resolved target version —
3312
+ // the same computation create-tag will use), tagCommit (the frozen
3313
+ // production asset commit the tag will point at; only production
3314
+ // prepares can freeze it — distribute fails closed when it is absent),
3315
+ // unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
3316
+ // contract: payload may only come from the detached worktree at
3317
+ // tagCommit, never from workspace state). planVersion 2 record-layer
3318
+ // stripping does not strip this block, so every declaration detail is
3319
+ // bound into the plan digest.
3320
+ let frozenPostPublish = null;
3321
+ if (postPublishDeclaration) {
3322
+ const { unit, index } = postPublishDeclaration;
3323
+ const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
3324
+ frozenPostPublish = {
3325
+ ...structuredClone(unit.postPublish),
3326
+ tag,
3327
+ ...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
3328
+ unitId: unit.id,
3329
+ payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
3330
+ };
3331
+ }
3332
+
2901
3333
  const plan = {
2902
3334
  planVersion: 2,
2903
3335
  status: 'PREPARED',
3336
+ // Workflow profile (H5): 'full' for the complete gate set;
3337
+ // 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
3338
+ // fields are binding-layer (not stripped from the plan digest), so the
3339
+ // trim is frozen immutably with the plan.
3340
+ workflowKind: workflow,
3341
+ ...(workflowDecision ? { workflowDecision } : {}),
2904
3342
  baseline: {
2905
3343
  gitTreeHash: baseline.gitTreeHash,
2906
3344
  headCommit: baseline.gitHead,
@@ -2910,14 +3348,18 @@ export async function prepareRelease(options) {
2910
3348
  capturedAt: baseline.capturedAt,
2911
3349
  },
2912
3350
  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
- },
3351
+ ...(skillResourceClosureResults.length > 0
3352
+ ? {
3353
+ skillResourceClosure: {
3354
+ checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
3355
+ unitReceipts: skillResourceClosureResults,
3356
+ totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
3357
+ totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
3358
+ totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
3359
+ totalFindingCount: 0,
3360
+ },
3361
+ }
3362
+ : {}),
2921
3363
  verificationGates: config.verificationGates ?? [],
2922
3364
  snapshotDigest: overallSnapshotDigest,
2923
3365
  ...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
@@ -2929,6 +3371,7 @@ export async function prepareRelease(options) {
2929
3371
  } : {}),
2930
3372
  units,
2931
3373
  externalActions,
3374
+ ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
2932
3375
  ...(sourceAuthority ? { sourceAuthority } : {}),
2933
3376
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
2934
3377
  };
@@ -2958,6 +3401,26 @@ export async function prepareRelease(options) {
2958
3401
  planDigest,
2959
3402
  });
2960
3403
 
3404
+ // --- Write the FROZEN governance marker (§4.5 option 1) ---
3405
+ // Mechanical maintenance only: the marker is a read-only gentlemen's-
3406
+ // agreement signal for cross-repo writers (e.g. skill-family治理 tasks)
3407
+ // that this workspace is mid-release. It is written ONLY after the plan
3408
+ // is frozen, overwritten by every successful prepare, and cleared by
3409
+ // verify only upon VERIFIED. A failed prepare never reaches this point.
3410
+ await writeFrozenMarker(releaseDir, {
3411
+ planDigest,
3412
+ targetVersions: Object.fromEntries(
3413
+ configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
3414
+ ),
3415
+ createdAt: plan.createdAt,
3416
+ runId: basename(runDir),
3417
+ });
3418
+ await evidence.append({
3419
+ phase: 'frozen-marker',
3420
+ status: 'written',
3421
+ markerPath: `.release-skill/${FROZEN_MARKER_FILENAME}`,
3422
+ });
3423
+
2961
3424
  // --- Write summary ---
2962
3425
  await evidence.finish({
2963
3426
  status: 'PREPARED',
@@ -2968,6 +3431,8 @@ export async function prepareRelease(options) {
2968
3431
  unitCount: units.length,
2969
3432
  actionCount: externalActions.length,
2970
3433
  offline,
3434
+ workflowKind: workflow,
3435
+ ...(workflowDecision ? { workflowDecision } : {}),
2971
3436
  completedAt: (clock ? clock() : new Date().toISOString()),
2972
3437
  });
2973
3438