release-skill 0.6.0 → 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 (36) 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 +14 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +9 -8
  10. package/README.zh-CN.md +9 -8
  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 +705 -356
  14. package/adapters/claude/schemas/release-project.schema.json +8 -0
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +705 -356
  17. package/adapters/codex/schemas/release-project.schema.json +8 -0
  18. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  19. package/adapters/kimi/bin/release-skill.bundle.mjs +705 -356
  20. package/adapters/kimi/schemas/release-project.schema.json +8 -0
  21. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  22. package/adapters/workbuddy/bin/release-skill.bundle.mjs +705 -356
  23. package/adapters/workbuddy/schemas/release-project.schema.json +8 -0
  24. package/bin/release-skill-cli.mjs +11 -0
  25. package/bin/release-skill.bundle.mjs +705 -356
  26. package/package.json +1 -1
  27. package/references/05-evidence-and-errors.md +1 -0
  28. package/schemas/release-project.schema.json +8 -0
  29. package/scripts/build-bundle.mjs +11 -2
  30. package/scripts/sync-public-files.mjs +4 -0
  31. package/src/commands/prepare.mjs +214 -8
  32. package/src/commands/verify.mjs +22 -0
  33. package/src/core/bundle-freshness.mjs +236 -0
  34. package/src/core/errors.mjs +2 -0
  35. package/src/core/frozen-marker.mjs +97 -0
  36. package/src/core/hooks.mjs +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -32,6 +32,7 @@
32
32
  | `NOT_DEFAULT` | 配置分支不是远端实际默认分支 | 默认分支在 prepare 后改变,或配置错误 | 人工确认远端默认分支,更新配置并重新 prepare |
33
33
  | `CONTENT_MISMATCH` | 远端默认分支缺少冻结源码内容 | README、版本源、公开映射输入的内容或 mode 不一致 | 人工 merge/adopt/reject;接受的内容进入默认分支后重试 publish |
34
34
  | `DIRTY_SOURCE_INPUT` | 源码输入闭包存在未提交变化 | `publicFiles.from` 或 `version.source` 有 staged、unstaged、untracked 变化 | 提交或撤销这些具体输入的变化后重新 prepare;无关 dirty 不受影响 |
35
+ | `BUNDLE_STALE` | bundle 与其源码输入失步(fail-closed 前置门禁) | `bin/release-skill.bundle.mjs` 内嵌的源码摘要与当前 `src/` 摘要不一致,或 bundle/内嵌摘要缺失;在 prepare 加载 config 后的早期阶段检测,任何 workflow 均不可豁免 | 按错误提示在 release-skill 包根运行 `node scripts/build-bundle.mjs`(或 `pnpm build`)重建 bundle 后重新 prepare |
35
36
 
36
37
  **动作状态码** (adapter execute/observe/verify):
37
38
 
@@ -873,6 +873,14 @@
873
873
  },
874
874
  "uniqueItems": true,
875
875
  "description": "Root-relative globs declaring the hook's full input set (source, scripts, config, lockfiles). The cache key fingerprints the content of every matched file; a glob that matches nothing fails closed. Must cover ALL inputs or the cache can return a false hit."
876
+ },
877
+ "testSelection": {
878
+ "type": "string",
879
+ "enum": [
880
+ "full",
881
+ "incremental"
882
+ ],
883
+ "description": "Only meaningful for the test hook: which test selection the hook command runs. Absence or 'full' means the hook runs the full suite, which prepare's built-in full-test freeze gate requires before the plan digest is computed. 'incremental' is reserved for a future preflight mode and is rejected at freeze time ('incremental selection is not allowed at freeze time'). Built-in gate; cannot be disabled by project overlays."
876
884
  }
877
885
  }
878
886
  },
@@ -21,6 +21,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
22
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
23
  import { createHash } from 'node:crypto';
24
+ import { computeBundleSourceDigest } from '../src/core/bundle-freshness.mjs';
24
25
 
25
26
  const CHECK_MODE = process.argv.includes('--check');
26
27
 
@@ -38,7 +39,12 @@ const OUTFILE = join(PKG_ROOT, 'bin', 'release-skill.bundle.mjs');
38
39
  // dependency: the Claude and Codex adapter closures ship the bundle at a
39
40
  // different depth with no package.json next to it, while the npm closure does.
40
41
  // Reading package.json here (build input) keeps the output deterministic.
41
- function buildBanner(pkgIdentity) {
42
+ //
43
+ // __bundleSourceDigest is the build-time digest of the bundle's source inputs
44
+ // (src/, skills-src/, bin/release-skill-cli.mjs, package.json — algorithm in
45
+ // src/core/bundle-freshness.mjs). prepare's BUNDLE_STALE gate recomputes the
46
+ // digest and fails closed on any mismatch (2026-08-18 investigation §4.2).
47
+ function buildBanner(pkgIdentity, sourceDigest) {
42
48
  return `\
43
49
  // --- release-skill bundle (deterministic build) ---
44
50
  // Compute package root from the bundle's own file location (import.meta.url).
@@ -51,6 +57,8 @@ const __bundlePkgRoot = __bundleResolve(__bundleDirname(__bundleFileURLToPath(im
51
57
  const __bundleRealRequire = __bundleCreateRequire(import.meta.url);
52
58
  // Package identity injected at build time — closure-independent --version probe.
53
59
  const __bundlePkg = Object.freeze(${JSON.stringify(pkgIdentity)});
60
+ // Build-time source digest for the BUNDLE_STALE freshness gate (see above).
61
+ const __bundleSourceDigest = "${sourceDigest}";
54
62
  `;
55
63
  }
56
64
 
@@ -164,7 +172,8 @@ async function buildBundle() {
164
172
  }
165
173
 
166
174
  const pkgJson = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf-8'));
167
- const banner = buildBanner({ name: pkgJson.name, version: pkgJson.version });
175
+ const sourceDigest = await computeBundleSourceDigest(PKG_ROOT);
176
+ const banner = buildBanner({ name: pkgJson.name, version: pkgJson.version }, sourceDigest);
168
177
 
169
178
  const identity = Object.freeze({ name: pkgJson.name, version: pkgJson.version });
170
179
 
@@ -232,6 +232,9 @@ async function collectEntries(packageRoot) {
232
232
  // a distribution artifact; the shipped bundle
233
233
  // inlines the adopted capability — matches the npm
234
234
  // pack closure contract)
235
+ // - .release-skill/ (local release runtime state — runs, plans, locks;
236
+ // not in the package.json files whitelist, so it is
237
+ // never part of the npm pack closure)
235
238
  const baseFiles = allFiles.filter((f) => {
236
239
  if (f.startsWith('adapters/')) return false;
237
240
  if (f.startsWith('test/')) return false;
@@ -240,6 +243,7 @@ async function collectEntries(packageRoot) {
240
243
  if (f.startsWith('native/') && f.includes('/build/')) return false;
241
244
  if (f.startsWith('scripts/')) return false;
242
245
  if (f.startsWith('vendor/')) return false;
246
+ if (f.startsWith('.release-skill/')) return false;
243
247
  return true;
244
248
  });
245
249
 
@@ -18,7 +18,7 @@
18
18
  * @module commands/prepare
19
19
  */
20
20
 
21
- import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
21
+ import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
22
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';
@@ -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,
@@ -224,6 +227,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
224
227
  // Hooks execution
225
228
  // ---------------------------------------------------------------------------
226
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
+
227
264
  /**
228
265
  * Run all declared project hooks in order: docs, build, test, typecheck.
229
266
  *
@@ -236,6 +273,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
236
273
  * Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
237
274
  * glob that matches nothing fails closed before the hook runs.
238
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
+ *
239
283
  * @param {object} config - The loaded project config.
240
284
  * @param {string} root - Absolute project root.
241
285
  * @param {object} evidence - The evidence writer.
@@ -251,7 +295,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
251
295
  * injectable. Defaults to process.env at the prepare call site, which makes
252
296
  * allowlisted keys exported by the invoking shell reach the hook
253
297
  * subprocess.
254
- * @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.
255
301
  * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
256
302
  * throws, or declares a cacheInputs glob that matches no file.
257
303
  */
@@ -259,15 +305,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
259
305
  const hookOrder = ['docs', 'build', 'test', 'typecheck'];
260
306
  const hooks = config.hooks ?? {};
261
307
  const cacheEnabled = options.hookCache !== false;
308
+ const records = [];
262
309
 
263
310
  for (const name of hookOrder) {
264
311
  const hook = hooks[name];
265
312
  if (!hook) continue;
266
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
+
267
321
  await evidence.append({
268
322
  phase: 'hooks',
269
323
  status: 'started',
270
324
  hookName: name,
325
+ ...selectionField,
271
326
  });
272
327
 
273
328
  // --- Incremental cache lookup (opt-in only; default zero change) ---
@@ -295,7 +350,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
295
350
  hookName: name,
296
351
  cached: true,
297
352
  cacheKey,
353
+ ...selectionField,
298
354
  });
355
+ records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
299
356
  continue;
300
357
  }
301
358
  }
@@ -312,6 +369,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
312
369
  status: 'failed',
313
370
  hookName: name,
314
371
  error: { code: err.code, message: err.message },
372
+ ...selectionField,
315
373
  });
316
374
  throw new ReleaseError(
317
375
  GATE_FAILED,
@@ -321,16 +379,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
321
379
  }
322
380
 
323
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
+ }
324
394
  await evidence.append({
325
395
  phase: 'hooks',
326
396
  status: 'failed',
327
397
  hookName: name,
328
398
  exitCode: result.exitCode,
329
399
  // Test runners usually emit the actionable failure summary at the
330
- // end. Preserve bounded tails of both streams instead of the noisy
331
- // compiler prelude at the beginning.
332
- stdoutTail: result.stdout.slice(-4000),
333
- 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,
334
405
  });
335
406
  throw new ReleaseError(
336
407
  GATE_FAILED,
@@ -364,8 +435,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
364
435
  status: 'completed',
365
436
  hookName: name,
366
437
  exitCode: 0,
438
+ ...selectionField,
367
439
  });
440
+ records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
368
441
  }
442
+
443
+ return records;
369
444
  }
370
445
 
371
446
  // ---------------------------------------------------------------------------
@@ -1744,6 +1819,7 @@ export async function prepareRelease(options) {
1744
1819
  production = false,
1745
1820
  workflow = 'full',
1746
1821
  observePreviousPublicBaselineFn,
1822
+ testSelection = 'full',
1747
1823
  } = options ?? {};
1748
1824
 
1749
1825
  // --- Workflow profile (H5) ---
@@ -1761,6 +1837,24 @@ export async function prepareRelease(options) {
1761
1837
  { workflow },
1762
1838
  );
1763
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
+ }
1764
1858
  const trimmedWorkflow = workflow !== 'full';
1765
1859
  const skipDeclaredHooks = trimmedWorkflow;
1766
1860
  const skipSnapshotVerifyGates = trimmedWorkflow;
@@ -1845,6 +1939,44 @@ export async function prepareRelease(options) {
1845
1939
  });
1846
1940
  }
1847
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
+
1848
1980
  // --- Step 1a: Workflow configuration evidence ---
1849
1981
  // Records the deterministic trim set for docs/config/marketplace
1850
1982
  // workflows. The trim never weakens the retained gates; it only removes
@@ -1974,6 +2106,7 @@ export async function prepareRelease(options) {
1974
2106
  }
1975
2107
 
1976
2108
  // --- Step 3: Run declared hooks ---
2109
+ let hookRecords = [];
1977
2110
  if (skipDeclaredHooks) {
1978
2111
  await evidence.append({
1979
2112
  phase: 'hooks',
@@ -1982,7 +2115,7 @@ export async function prepareRelease(options) {
1982
2115
  });
1983
2116
  } else {
1984
2117
  await evidence.append({ phase: 'hooks', status: 'started' });
1985
- await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
2118
+ hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1986
2119
  hookCache: options.hookCache,
1987
2120
  // Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
1988
2121
  // reads envAllowlist keys exclusively from context.env, so the invoking
@@ -2608,6 +2741,59 @@ export async function prepareRelease(options) {
2608
2741
  // --- Step 7: Build plan object ---
2609
2742
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
2610
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
+
2611
2797
  // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
2612
2798
  // §4.2/§7). Production freeze timestamps are derived deterministically
2613
2799
  // from the baseline headCommit's committer date, before the first frozen
@@ -3215,6 +3401,26 @@ export async function prepareRelease(options) {
3215
3401
  planDigest,
3216
3402
  });
3217
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
+
3218
3424
  // --- Write summary ---
3219
3425
  await evidence.finish({
3220
3426
  status: 'PREPARED',
@@ -54,6 +54,7 @@ import {
54
54
  } from '../core/baseline-advance.mjs';
55
55
  import { loadProjectConfig } from '../core/config.mjs';
56
56
  import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
57
+ import { clearFrozenMarker } from '../core/frozen-marker.mjs';
57
58
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
58
59
  import {
59
60
  normalizeRegistry,
@@ -1802,6 +1803,27 @@ export async function verifyRelease(options) {
1802
1803
  };
1803
1804
  const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
1804
1805
 
1806
+ // =======================================================================
1807
+ // FROZEN marker retirement (2026-08-18 investigation §4.5 option 1):
1808
+ // the release reached VERIFIED, so the freeze signal is cleared. Only
1809
+ // this success path clears it — failed/PARTIAL verify runs never reach
1810
+ // here and keep the marker. Best-effort: a removal failure must never
1811
+ // demote VERIFIED.
1812
+ // =======================================================================
1813
+ try {
1814
+ const removed = await clearFrozenMarker(join(root, '.release-skill'));
1815
+ await evidence.append({
1816
+ phase: 'frozen-marker',
1817
+ status: removed ? 'cleared' : 'absent',
1818
+ });
1819
+ } catch (err) {
1820
+ await evidence.append({
1821
+ phase: 'frozen-marker',
1822
+ status: 'clear-failed',
1823
+ error: { code: err.code, message: err.message },
1824
+ });
1825
+ }
1826
+
1805
1827
  // =======================================================================
1806
1828
  // Baseline advance: move per-unit previousPublicBaseline to the commit
1807
1829
  // that was just verified. Local bookkeeping only — no remote writes.