release-skill 0.6.0 → 0.6.2

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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +31 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +12 -8
  10. package/README.zh-CN.md +12 -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 +1028 -423
  14. package/adapters/claude/schemas/release-plan.schema.json +9 -0
  15. package/adapters/claude/schemas/release-project.schema.json +8 -0
  16. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  17. package/adapters/codex/bin/release-skill.bundle.mjs +1028 -423
  18. package/adapters/codex/schemas/release-plan.schema.json +9 -0
  19. package/adapters/codex/schemas/release-project.schema.json +8 -0
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1028 -423
  22. package/adapters/kimi/schemas/release-plan.schema.json +9 -0
  23. package/adapters/kimi/schemas/release-project.schema.json +8 -0
  24. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  25. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1028 -423
  26. package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
  27. package/adapters/workbuddy/schemas/release-project.schema.json +8 -0
  28. package/bin/release-skill-cli.mjs +11 -0
  29. package/bin/release-skill.bundle.mjs +1028 -423
  30. package/package.json +1 -1
  31. package/references/05-evidence-and-errors.md +1 -0
  32. package/schemas/release-plan.schema.json +9 -0
  33. package/schemas/release-project.schema.json +8 -0
  34. package/scripts/build-bundle.mjs +11 -2
  35. package/scripts/sync-public-files.mjs +4 -0
  36. package/src/commands/lineage.mjs +101 -32
  37. package/src/commands/prepare.mjs +273 -9
  38. package/src/commands/publish.mjs +10 -1
  39. package/src/commands/verify.mjs +22 -0
  40. package/src/core/bundle-freshness.mjs +236 -0
  41. package/src/core/errors.mjs +2 -0
  42. package/src/core/frozen-marker.mjs +97 -0
  43. package/src/core/hooks.mjs +12 -1
  44. package/src/core/skill-resource-closure.mjs +240 -10
  45. package/src/platforms/registry.mjs +12 -0
@@ -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';
@@ -41,6 +41,7 @@ import {
41
41
  CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
42
42
  checkSkillResourceClosure,
43
43
  createSkillResourceClosureReceipt,
44
+ evaluateDeclaredHostSurfaceCoverage,
44
45
  } from '../core/skill-resource-closure.mjs';
45
46
  import { buildPublicStaging } from '../snapshot/public-map.mjs';
46
47
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
@@ -53,7 +54,10 @@ import {
53
54
  normalizeGitTimestamp,
54
55
  sealFrozenSnapshot,
55
56
  } 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';
57
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
58
+ import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
59
+ import { PKG_ROOT } from '../core/pkg-root.mjs';
60
+ import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
57
61
  import {
58
62
  SOURCE_INPUT_ALGORITHM_VERSION,
59
63
  computeSourceInputClosure,
@@ -224,6 +228,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
224
228
  // Hooks execution
225
229
  // ---------------------------------------------------------------------------
226
230
 
231
+ /** Maximum number of output lines preserved in a hook-failure tail. */
232
+ const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
233
+ /** Maximum bytes preserved in a hook-failure tail. */
234
+ const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
235
+
236
+ /**
237
+ * Bound a captured child-output stream to the tail that matters for triage:
238
+ * the last 50 lines, further capped at 8 KB — whichever is smaller.
239
+ *
240
+ * @param {string} [text] - Captured stdout/stderr text.
241
+ * @returns {string} The bounded tail ('' for empty/absent input).
242
+ */
243
+ export function boundedOutputTail(text) {
244
+ if (typeof text !== 'string' || text.length === 0) return '';
245
+ let lines = text.split('\n');
246
+ // A trailing newline produces an empty final element; drop it so the line
247
+ // budget counts real output lines.
248
+ if (lines.length > 1 && lines[lines.length - 1] === '') {
249
+ lines = lines.slice(0, -1);
250
+ }
251
+ let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
252
+ let joined = tail.join('\n');
253
+ while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
254
+ tail = tail.slice(1);
255
+ joined = tail.join('\n');
256
+ }
257
+ if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
258
+ // A single line exceeds the byte cap: keep the trailing bytes.
259
+ const buf = Buffer.from(joined, 'utf8');
260
+ joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
261
+ }
262
+ return joined;
263
+ }
264
+
227
265
  /**
228
266
  * Run all declared project hooks in order: docs, build, test, typecheck.
229
267
  *
@@ -236,6 +274,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
236
274
  * Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
237
275
  * glob that matches nothing fails closed before the hook runs.
238
276
  *
277
+ * Failure output passthrough (2026-08-18 investigation §4.1): the executor
278
+ * already captures child stdout/stderr on non-zero exit; on failure this
279
+ * layer writes bounded tails into the hooks evidence event AND echoes them to
280
+ * the current process' stderr, so a failing hook is diagnosable on the
281
+ * terminal without opening evidence.jsonl. Success events carry no tails.
282
+ * Exit-code semantics are unchanged.
283
+ *
239
284
  * @param {object} config - The loaded project config.
240
285
  * @param {string} root - Absolute project root.
241
286
  * @param {object} evidence - The evidence writer.
@@ -251,7 +296,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
251
296
  * injectable. Defaults to process.env at the prepare call site, which makes
252
297
  * allowlisted keys exported by the invoking shell reach the hook
253
298
  * subprocess.
254
- * @returns {Promise<void>}
299
+ * @returns {Promise<Array<{ name: string, completed: boolean, cached: boolean, testSelection: string | undefined }>>}
300
+ * One record per declared hook that completed (fresh or cached replay).
301
+ * Failures throw instead of returning a record.
255
302
  * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
256
303
  * throws, or declares a cacheInputs glob that matches no file.
257
304
  */
@@ -259,15 +306,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
259
306
  const hookOrder = ['docs', 'build', 'test', 'typecheck'];
260
307
  const hooks = config.hooks ?? {};
261
308
  const cacheEnabled = options.hookCache !== false;
309
+ const records = [];
262
310
 
263
311
  for (const name of hookOrder) {
264
312
  const hook = hooks[name];
265
313
  if (!hook) continue;
266
314
 
315
+ // Test-selection evidence (2026-08-18 investigation §4.4): the test hook
316
+ // records whether it ran the full suite. Absence of a declaration means
317
+ // 'full' — every existing config stays backward compatible.
318
+ const selectionField = name === 'test'
319
+ ? { testSelection: hook.testSelection === 'incremental' ? 'incremental' : 'full' }
320
+ : {};
321
+
267
322
  await evidence.append({
268
323
  phase: 'hooks',
269
324
  status: 'started',
270
325
  hookName: name,
326
+ ...selectionField,
271
327
  });
272
328
 
273
329
  // --- Incremental cache lookup (opt-in only; default zero change) ---
@@ -295,7 +351,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
295
351
  hookName: name,
296
352
  cached: true,
297
353
  cacheKey,
354
+ ...selectionField,
298
355
  });
356
+ records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
299
357
  continue;
300
358
  }
301
359
  }
@@ -312,6 +370,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
312
370
  status: 'failed',
313
371
  hookName: name,
314
372
  error: { code: err.code, message: err.message },
373
+ ...selectionField,
315
374
  });
316
375
  throw new ReleaseError(
317
376
  GATE_FAILED,
@@ -321,16 +380,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
321
380
  }
322
381
 
323
382
  if (result.exitCode !== 0) {
383
+ const stdoutTail = boundedOutputTail(result.stdout);
384
+ const stderrTail = boundedOutputTail(result.stderr);
385
+ // Echo the captured tails to the current process' stderr so a failing
386
+ // hook is diagnosable on the terminal without opening evidence.jsonl
387
+ // (2026-08-18 investigation §4.1). Exit-code semantics are untouched.
388
+ process.stderr.write(`[release-skill] hook "${name}" failed with exit code ${result.exitCode}\n`);
389
+ if (stdoutTail) {
390
+ process.stderr.write(`[release-skill] hook "${name}" stdout tail:\n${stdoutTail}\n`);
391
+ }
392
+ if (stderrTail) {
393
+ process.stderr.write(`[release-skill] hook "${name}" stderr tail:\n${stderrTail}\n`);
394
+ }
324
395
  await evidence.append({
325
396
  phase: 'hooks',
326
397
  status: 'failed',
327
398
  hookName: name,
328
399
  exitCode: result.exitCode,
329
400
  // 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),
401
+ // end. Preserve bounded tails (last 50 lines, capped at 8 KB) of both
402
+ // streams instead of the noisy compiler prelude at the beginning.
403
+ stdoutTail,
404
+ stderrTail,
405
+ ...selectionField,
334
406
  });
335
407
  throw new ReleaseError(
336
408
  GATE_FAILED,
@@ -364,8 +436,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
364
436
  status: 'completed',
365
437
  hookName: name,
366
438
  exitCode: 0,
439
+ ...selectionField,
367
440
  });
441
+ records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
368
442
  }
443
+
444
+ return records;
369
445
  }
370
446
 
371
447
  // ---------------------------------------------------------------------------
@@ -1744,6 +1820,7 @@ export async function prepareRelease(options) {
1744
1820
  production = false,
1745
1821
  workflow = 'full',
1746
1822
  observePreviousPublicBaselineFn,
1823
+ testSelection = 'full',
1747
1824
  } = options ?? {};
1748
1825
 
1749
1826
  // --- Workflow profile (H5) ---
@@ -1761,6 +1838,24 @@ export async function prepareRelease(options) {
1761
1838
  { workflow },
1762
1839
  );
1763
1840
  }
1841
+
1842
+ // --- Test selection (2026-08-18 investigation §4.4, review §3.4) ---
1843
+ // Design decision: prepare IS the freeze, so incremental test selection is
1844
+ // rejected outright; the flag is reserved for a future preflight mode.
1845
+ if (testSelection === 'incremental') {
1846
+ throw new ReleaseError(
1847
+ GATE_FAILED,
1848
+ 'incremental selection is not allowed at freeze time',
1849
+ { testSelection, reservedFor: 'preflight mode' },
1850
+ );
1851
+ }
1852
+ if (testSelection !== 'full') {
1853
+ throw new ReleaseError(
1854
+ CONFIG_INVALID,
1855
+ `unknown testSelection "${testSelection}"; expected "full" or "incremental"`,
1856
+ { testSelection },
1857
+ );
1858
+ }
1764
1859
  const trimmedWorkflow = workflow !== 'full';
1765
1860
  const skipDeclaredHooks = trimmedWorkflow;
1766
1861
  const skipSnapshotVerifyGates = trimmedWorkflow;
@@ -1845,6 +1940,44 @@ export async function prepareRelease(options) {
1845
1940
  });
1846
1941
  }
1847
1942
 
1943
+ // --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
1944
+ // 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
1945
+ // used to surface only deep inside test hooks. Compare the deterministic
1946
+ // source digest embedded in the bundle at build time with the current
1947
+ // sources at the earliest stage — config loaded, before any hook.
1948
+ // This gate is artifact-integrity class, NOT a code-class gate:
1949
+ // docs/config/marketplace workflow trimming must never exempt it.
1950
+ await evidence.append({ phase: 'bundle-freshness', status: 'started' });
1951
+ const bundleFreshnessFn = options.bundleFreshnessFn ?? assertBundleFreshness;
1952
+ let bundleFreshness;
1953
+ try {
1954
+ bundleFreshness = await bundleFreshnessFn(PKG_ROOT);
1955
+ } catch (err) {
1956
+ await evidence.append({
1957
+ phase: 'bundle-freshness',
1958
+ status: 'blocking',
1959
+ reason: err.details?.reason ?? null,
1960
+ error: { code: err.code, message: err.message },
1961
+ });
1962
+ throw err;
1963
+ }
1964
+ if (bundleFreshness?.applicable === false) {
1965
+ // Installed distributions ship no mutable src/ next to the bundle;
1966
+ // staleness is a source-checkout concern only.
1967
+ await evidence.append({
1968
+ phase: 'bundle-freshness',
1969
+ status: 'not-applicable',
1970
+ reason: bundleFreshness.reason,
1971
+ });
1972
+ } else {
1973
+ await evidence.append({
1974
+ phase: 'bundle-freshness',
1975
+ status: 'completed',
1976
+ algorithm: bundleFreshness?.algorithm ?? null,
1977
+ sourceDigest: bundleFreshness?.sourceDigest ?? null,
1978
+ });
1979
+ }
1980
+
1848
1981
  // --- Step 1a: Workflow configuration evidence ---
1849
1982
  // Records the deterministic trim set for docs/config/marketplace
1850
1983
  // workflows. The trim never weakens the retained gates; it only removes
@@ -1974,6 +2107,7 @@ export async function prepareRelease(options) {
1974
2107
  }
1975
2108
 
1976
2109
  // --- Step 3: Run declared hooks ---
2110
+ let hookRecords = [];
1977
2111
  if (skipDeclaredHooks) {
1978
2112
  await evidence.append({
1979
2113
  phase: 'hooks',
@@ -1982,7 +2116,7 @@ export async function prepareRelease(options) {
1982
2116
  });
1983
2117
  } else {
1984
2118
  await evidence.append({ phase: 'hooks', status: 'started' });
1985
- await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
2119
+ hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
1986
2120
  hookCache: options.hookCache,
1987
2121
  // Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
1988
2122
  // reads envAllowlist keys exclusively from context.env, so the invoking
@@ -2608,6 +2742,59 @@ export async function prepareRelease(options) {
2608
2742
  // --- Step 7: Build plan object ---
2609
2743
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
2610
2744
 
2745
+ // --- Step 7-gate: Full-test freeze gate (hard gate) ---
2746
+ // 2026-08-18 investigation §4.4 / review §3.4: "full test suite before
2747
+ // freeze" is a HARD gate, not a convention. The plan digest may only be
2748
+ // computed after a completed FULL-mode test hook exists in THIS run's
2749
+ // evidence (fresh run or a cached replay of a successful full run).
2750
+ // Built-in and read-only — like secret-scan, plan-digest binding, and
2751
+ // approval, it cannot be disabled by project overlays. Workflows that
2752
+ // trim declared hooks record the trim; projects declaring no test hook
2753
+ // pass vacuously (nothing can run incrementally there).
2754
+ if (skipDeclaredHooks) {
2755
+ await evidence.append({
2756
+ phase: 'full-test-gate',
2757
+ status: 'skipped',
2758
+ reason: `workflow "${workflow}" trims declared hooks`,
2759
+ });
2760
+ } else if (!config.hooks?.test) {
2761
+ await evidence.append({
2762
+ phase: 'full-test-gate',
2763
+ status: 'not-declared',
2764
+ reason: 'no test hook declared; nothing can run incrementally',
2765
+ });
2766
+ } else {
2767
+ const testRecord = hookRecords.find((record) => record.name === 'test');
2768
+ const satisfied = Boolean(
2769
+ testRecord && testRecord.completed && testRecord.testSelection === 'full',
2770
+ );
2771
+ if (!satisfied) {
2772
+ await evidence.append({
2773
+ phase: 'full-test-gate',
2774
+ status: 'blocking',
2775
+ testSelection: testRecord?.testSelection ?? null,
2776
+ cached: Boolean(testRecord?.cached),
2777
+ });
2778
+ throw new ReleaseError(
2779
+ GATE_FAILED,
2780
+ testRecord?.testSelection === 'incremental'
2781
+ ? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
2782
+ : 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
2783
+ {
2784
+ gate: 'full-test-freeze',
2785
+ testSelection: testRecord?.testSelection ?? null,
2786
+ cached: Boolean(testRecord?.cached),
2787
+ },
2788
+ );
2789
+ }
2790
+ await evidence.append({
2791
+ phase: 'full-test-gate',
2792
+ status: 'completed',
2793
+ testSelection: 'full',
2794
+ cached: Boolean(testRecord.cached),
2795
+ });
2796
+ }
2797
+
2611
2798
  // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
2612
2799
  // §4.2/§7). Production freeze timestamps are derived deterministically
2613
2800
  // from the baseline headCommit's committer date, before the first frozen
@@ -2672,7 +2859,16 @@ export async function prepareRelease(options) {
2672
2859
  host: 'root',
2673
2860
  });
2674
2861
 
2675
- const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2862
+ // G5: bind execution time + exit code into the frozen receipt.
2863
+ // preparedAt reuses this prepare's deterministic freeze timestamp
2864
+ // (production: the baseline HEAD commit committer date; otherwise
2865
+ // null) — never a wall-clock sample — so identical sources freeze
2866
+ // byte-identical receipts on every re-prepare.
2867
+ const receipt = createSkillResourceClosureReceipt(closureResult, {
2868
+ unitId: unit.id,
2869
+ preparedAt: freezeTimestamp ?? null,
2870
+ exitCode: 0,
2871
+ });
2676
2872
  skillResourceClosureResults.push(receipt);
2677
2873
 
2678
2874
  if (closureResult.findings.length > 0) {
@@ -2687,6 +2883,9 @@ export async function prepareRelease(options) {
2687
2883
  reference: f.reference,
2688
2884
  classification: f.classification,
2689
2885
  code: f.code,
2886
+ // D4: RESOURCE_DRIFT findings localize via references (the
2887
+ // finding's own skill/line stay null for a cross-surface drift).
2888
+ ...(f.references ? { references: f.references } : {}),
2690
2889
  })),
2691
2890
  });
2692
2891
  throw new ReleaseError(
@@ -2700,6 +2899,47 @@ export async function prepareRelease(options) {
2700
2899
  );
2701
2900
  }
2702
2901
 
2902
+ // G4: every declared plugin distribution must be backed by a host
2903
+ // surface in the frozen snapshot with at least one skill. If
2904
+ // publicFiles drops an adapter tree, that host surface is silently
2905
+ // absent from the receipt — fail closed here instead of shipping a
2906
+ // unit whose declared host never entered the closure gate.
2907
+ // Expected host names are the adapter directory names declared by the
2908
+ // platform registry (buildAdapter.name, asserted non-empty by
2909
+ // assertRegistry); codebuddy-plugin keeps the historical `workbuddy`
2910
+ // adapter directory name there. npm-only units declare no plugin
2911
+ // hosts and skip.
2912
+ const expectedHosts = (unit.distributions ?? [])
2913
+ .map((distribution) => PLATFORMS.find((platform) => platform.distributionType === distribution.type))
2914
+ .filter(Boolean)
2915
+ .map((platform) => platform.buildAdapter.name);
2916
+ const hostCoverage = evaluateDeclaredHostSurfaceCoverage(
2917
+ expectedHosts,
2918
+ closureResult.surfaces,
2919
+ );
2920
+ if (!hostCoverage.passed) {
2921
+ await evidence.append({
2922
+ phase: 'skill-resource-closure',
2923
+ status: 'blocking',
2924
+ unitId: unit.id,
2925
+ reason: 'declared-host-surface-missing',
2926
+ missingHosts: hostCoverage.missing,
2927
+ });
2928
+ throw new ReleaseError(
2929
+ GATE_FAILED,
2930
+ `skill resource closure gate failed for unit "${unit.id}": declared host surface(s) missing or empty: ${hostCoverage.missing.map((item) => item.host).join(', ')}`,
2931
+ {
2932
+ unitId: unit.id,
2933
+ missingHosts: hostCoverage.missing,
2934
+ observedSurfaces: closureResult.surfaces.map((surface) => ({
2935
+ id: surface.id,
2936
+ host: surface.host,
2937
+ skillCount: surface.skillCount,
2938
+ })),
2939
+ },
2940
+ );
2941
+ }
2942
+
2703
2943
  await evidence.append({
2704
2944
  phase: 'skill-resource-closure',
2705
2945
  status: 'completed',
@@ -2709,6 +2949,10 @@ export async function prepareRelease(options) {
2709
2949
  skillCount: receipt.skillCount,
2710
2950
  referenceCount: closureResult.referenceCount,
2711
2951
  sourceOnlyCount: closureResult.sourceOnlyCount,
2952
+ // D2: per-reference exemption detail for approval/audit review —
2953
+ // evidence-layer only; the receipt object (and its digest binding)
2954
+ // is intentionally left unchanged.
2955
+ sourceOnlyReferences: closureResult.sourceOnlyReferences,
2712
2956
  findingCount: 0,
2713
2957
  receiptDigest: closureResult.receiptDigest,
2714
2958
  });
@@ -3215,6 +3459,26 @@ export async function prepareRelease(options) {
3215
3459
  planDigest,
3216
3460
  });
3217
3461
 
3462
+ // --- Write the FROZEN governance marker (§4.5 option 1) ---
3463
+ // Mechanical maintenance only: the marker is a read-only gentlemen's-
3464
+ // agreement signal for cross-repo writers (e.g. skill-family治理 tasks)
3465
+ // that this workspace is mid-release. It is written ONLY after the plan
3466
+ // is frozen, overwritten by every successful prepare, and cleared by
3467
+ // verify only upon VERIFIED. A failed prepare never reaches this point.
3468
+ await writeFrozenMarker(releaseDir, {
3469
+ planDigest,
3470
+ targetVersions: Object.fromEntries(
3471
+ configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
3472
+ ),
3473
+ createdAt: plan.createdAt,
3474
+ runId: basename(runDir),
3475
+ });
3476
+ await evidence.append({
3477
+ phase: 'frozen-marker',
3478
+ status: 'written',
3479
+ markerPath: `.release-skill/${FROZEN_MARKER_FILENAME}`,
3480
+ });
3481
+
3218
3482
  // --- Write summary ---
3219
3483
  await evidence.finish({
3220
3484
  status: 'PREPARED',
@@ -652,7 +652,16 @@ export async function publishRelease(options) {
652
652
  { unitId, findings: closureResult.findings },
653
653
  );
654
654
  }
655
- const observed = createSkillResourceClosureReceipt(closureResult, { unitId });
655
+ // G5: preparedAt/exitCode are record-layer fields frozen by prepare
656
+ // (bound by the plan digest); they cannot be recomputed from the
657
+ // snapshot, so the recheck carries them forward from the expected
658
+ // receipt and the strict comparison below verifies every re-derivable
659
+ // field against the frozen snapshot.
660
+ const observed = createSkillResourceClosureReceipt(closureResult, {
661
+ unitId,
662
+ preparedAt: expected.preparedAt ?? null,
663
+ exitCode: expected.exitCode ?? 0,
664
+ });
656
665
  assertSkillResourceClosureReceipt(expected, observed, `unit "${unitId}"`);
657
666
  }
658
667
 
@@ -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.