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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Bundle freshness authority (2026-08-18 release-cycle investigation §4.2).
3
+ *
4
+ * `bin/release-skill.bundle.mjs` is the shipped form of `src/`. When the two
5
+ * drift, failures used to surface deep inside test hooks; this module gives
6
+ * prepare an earliest-stage, fail-closed staleness gate:
7
+ *
8
+ * - The build script computes a deterministic digest over the bundle's source
9
+ * inputs (src/, skills-src/ when present, bin/release-skill-cli.mjs,
10
+ * package.json) and embeds it in the bundle banner as a build-time
11
+ * constant (`__bundleSourceDigest`).
12
+ * - prepare recomputes the source digest and compares it with the embedded
13
+ * constant. Any mismatch — including a missing bundle or a bundle without
14
+ * the constant — fails closed with BUNDLE_STALE.
15
+ * - Installed distributions ship no mutable src/ next to the bundle, so the
16
+ * gate is not applicable there; it records that fact instead of failing.
17
+ *
18
+ * The digest algorithm is shared with scripts/build-bundle.mjs so build and
19
+ * gate can never disagree about what "in sync" means.
20
+ *
21
+ * @module core/bundle-freshness
22
+ */
23
+
24
+ import { readFile, readdir, stat } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { createHash } from 'node:crypto';
27
+ import { ReleaseError, BUNDLE_STALE } from './errors.mjs';
28
+
29
+ /**
30
+ * Digest algorithm marker. Bump when the input set or hashing scheme changes;
31
+ * the value participates in the digest so old embedded digests never match.
32
+ */
33
+ export const BUNDLE_SOURCE_DIGEST_ALGORITHM = 'bundle-source-digest-v1';
34
+
35
+ /** Bundle file name, relative to the package root. */
36
+ const BUNDLE_RELPATH = join('bin', 'release-skill.bundle.mjs');
37
+
38
+ /**
39
+ * Directories whose entire content participates in the source digest.
40
+ * skills-src is included although esbuild does not inline it: the bundle is
41
+ * the release artifact's build authority, and a skills-src change still
42
+ * requires a rebuild so the embedded digest stays current.
43
+ */
44
+ const SOURCE_DIRS = ['src', 'skills-src'];
45
+
46
+ /** Individual files that participate in the source digest. */
47
+ const SOURCE_FILES = [join('bin', 'release-skill-cli.mjs'), 'package.json'];
48
+
49
+ /** Pattern matching the build-time digest constant embedded in the banner. */
50
+ const EMBEDDED_DIGEST_PATTERN = /const __bundleSourceDigest = "([a-f0-9]{64})";/;
51
+
52
+ /**
53
+ * Absolute path of the bundle for a package root.
54
+ *
55
+ * @param {string} pkgRoot - Absolute package root.
56
+ * @returns {string}
57
+ */
58
+ export function bundlePathFor(pkgRoot) {
59
+ return join(pkgRoot, BUNDLE_RELPATH);
60
+ }
61
+
62
+ /**
63
+ * Recursively list files under a directory as sorted relative paths.
64
+ * Uses '/' separators for cross-platform determinism.
65
+ *
66
+ * @param {string} dir - Absolute directory.
67
+ * @param {string} [prefix] - Internal recursion prefix.
68
+ * @returns {Promise<string[]>}
69
+ */
70
+ async function listFilesSorted(dir, prefix = '') {
71
+ const entries = await readdir(dir, { withFileTypes: true });
72
+ const files = [];
73
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
74
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
75
+ if (entry.isDirectory()) {
76
+ files.push(...(await listFilesSorted(join(dir, entry.name), rel)));
77
+ } else if (entry.isFile()) {
78
+ files.push(rel);
79
+ }
80
+ }
81
+ return files;
82
+ }
83
+
84
+ /**
85
+ * Enumerate the bundle's source inputs as relative paths (sorted, stable).
86
+ * Missing optional inputs (skills-src/, individual files) are skipped; the
87
+ * caller decides whether the resulting set is meaningful.
88
+ *
89
+ * @param {string} pkgRoot - Absolute package root.
90
+ * @returns {Promise<string[]>}
91
+ */
92
+ export async function listBundleSourceInputs(pkgRoot) {
93
+ const inputs = [];
94
+ for (const dir of SOURCE_DIRS) {
95
+ const abs = join(pkgRoot, dir);
96
+ const st = await stat(abs).catch(() => null);
97
+ if (st?.isDirectory()) {
98
+ for (const rel of await listFilesSorted(abs)) {
99
+ inputs.push(`${dir}/${rel}`);
100
+ }
101
+ }
102
+ }
103
+ for (const rel of SOURCE_FILES) {
104
+ const st = await stat(join(pkgRoot, rel)).catch(() => null);
105
+ if (st?.isFile()) {
106
+ inputs.push(rel.split(/[\\/]/).join('/'));
107
+ }
108
+ }
109
+ return inputs.sort();
110
+ }
111
+
112
+ /**
113
+ * Compute the deterministic source digest over the bundle's source inputs.
114
+ *
115
+ * @param {string} pkgRoot - Absolute package root.
116
+ * @returns {Promise<string>} sha256 hex digest.
117
+ */
118
+ export async function computeBundleSourceDigest(pkgRoot) {
119
+ const inputs = await listBundleSourceInputs(pkgRoot);
120
+ const hash = createHash('sha256');
121
+ hash.update(BUNDLE_SOURCE_DIGEST_ALGORITHM);
122
+ hash.update('\n');
123
+ for (const rel of inputs) {
124
+ const content = await readFile(join(pkgRoot, rel));
125
+ hash.update(rel);
126
+ hash.update('\0');
127
+ hash.update(createHash('sha256').update(content).digest('hex'));
128
+ hash.update('\n');
129
+ }
130
+ return hash.digest('hex');
131
+ }
132
+
133
+ /**
134
+ * Extract the embedded build-time source digest from a bundle's text.
135
+ *
136
+ * @param {string} bundlePath - Absolute bundle path.
137
+ * @returns {Promise<string | null>} The digest, or null when the bundle is
138
+ * missing or carries no digest constant.
139
+ */
140
+ export async function readEmbeddedBundleDigest(bundlePath) {
141
+ let content;
142
+ try {
143
+ content = await readFile(bundlePath, 'utf8');
144
+ } catch {
145
+ return null;
146
+ }
147
+ const match = content.match(EMBEDDED_DIGEST_PATTERN);
148
+ return match ? match[1] : null;
149
+ }
150
+
151
+ /**
152
+ * Decide bundle freshness for a package root (pure decision, no throw).
153
+ *
154
+ * @param {string} pkgRoot - Absolute package root.
155
+ * @returns {Promise<{
156
+ * applicable: boolean,
157
+ * fresh: boolean,
158
+ * reason: 'installed-layout' | 'bundle-missing' | 'digest-missing' | 'digest-mismatch' | 'fresh',
159
+ * embeddedDigest: string | null,
160
+ * sourceDigest: string | null,
161
+ * algorithm: string,
162
+ * }>}
163
+ */
164
+ export async function checkBundleFreshness(pkgRoot) {
165
+ const base = {
166
+ applicable: true,
167
+ fresh: false,
168
+ embeddedDigest: null,
169
+ sourceDigest: null,
170
+ algorithm: BUNDLE_SOURCE_DIGEST_ALGORITHM,
171
+ };
172
+
173
+ // Installed distributions ship the bundle without mutable sources next to
174
+ // it; staleness is a source-checkout concern only.
175
+ const srcStat = await stat(join(pkgRoot, 'src')).catch(() => null);
176
+ if (!srcStat?.isDirectory()) {
177
+ return { ...base, applicable: false, reason: 'installed-layout' };
178
+ }
179
+
180
+ const bundleStat = await stat(bundlePathFor(pkgRoot)).catch(() => null);
181
+ if (!bundleStat?.isFile()) {
182
+ return { ...base, reason: 'bundle-missing' };
183
+ }
184
+
185
+ const [embeddedDigest, sourceDigest] = await Promise.all([
186
+ readEmbeddedBundleDigest(bundlePathFor(pkgRoot)),
187
+ computeBundleSourceDigest(pkgRoot),
188
+ ]);
189
+
190
+ if (!embeddedDigest) {
191
+ return { ...base, sourceDigest, reason: 'digest-missing' };
192
+ }
193
+ if (embeddedDigest !== sourceDigest) {
194
+ return { ...base, embeddedDigest, sourceDigest, reason: 'digest-mismatch' };
195
+ }
196
+ return { ...base, fresh: true, embeddedDigest, sourceDigest, reason: 'fresh' };
197
+ }
198
+
199
+ /** Human-facing rebuild instruction embedded in every BUNDLE_STALE error. */
200
+ export const BUNDLE_REBUILD_COMMAND = 'node scripts/build-bundle.mjs';
201
+
202
+ /**
203
+ * Fail-closed freshness assertion used by prepare's earliest stage.
204
+ *
205
+ * Not applicable layouts return quietly; every stale/undecidable state in a
206
+ * source checkout throws BUNDLE_STALE with the rebuild command.
207
+ *
208
+ * @param {string} pkgRoot - Absolute package root.
209
+ * @returns {Promise<object>} The freshness decision (see checkBundleFreshness).
210
+ * @throws {ReleaseError} BUNDLE_STALE when the bundle is stale or undecidable.
211
+ */
212
+ export async function assertBundleFreshness(pkgRoot) {
213
+ const result = await checkBundleFreshness(pkgRoot);
214
+ if (!result.applicable) {
215
+ return result;
216
+ }
217
+ if (result.fresh) {
218
+ return result;
219
+ }
220
+ const reasonText = {
221
+ 'bundle-missing': 'the bundle file is missing',
222
+ 'digest-missing': 'the bundle carries no embedded source digest',
223
+ 'digest-mismatch': 'the bundle is out of sync with src/',
224
+ }[result.reason] ?? result.reason;
225
+ throw new ReleaseError(
226
+ BUNDLE_STALE,
227
+ `bin/release-skill.bundle.mjs is stale: ${reasonText}. Rebuild it from the release-skill package root with: ${BUNDLE_REBUILD_COMMAND} (or pnpm build)`,
228
+ {
229
+ reason: result.reason,
230
+ algorithm: result.algorithm,
231
+ embeddedDigest: result.embeddedDigest,
232
+ sourceDigest: result.sourceDigest,
233
+ rebuildCommand: BUNDLE_REBUILD_COMMAND,
234
+ },
235
+ );
236
+ }
@@ -94,6 +94,7 @@ const EXIT_CODE_MAP = Object.freeze({
94
94
  NOT_DEFAULT: 51,
95
95
  CONTENT_MISMATCH: 52,
96
96
  DIRTY_SOURCE_INPUT: 53,
97
+ BUNDLE_STALE: 54,
97
98
  });
98
99
 
99
100
  // ---- Error code constants ----
@@ -142,6 +143,7 @@ export const REF_MISSING = 'REF_MISSING';
142
143
  export const NOT_DEFAULT = 'NOT_DEFAULT';
143
144
  export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
144
145
  export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
146
+ export const BUNDLE_STALE = 'BUNDLE_STALE';
145
147
 
146
148
  /**
147
149
  * Typed error for release-skill operations.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * FROZEN marker maintenance (2026-08-18 release-cycle investigation §4.5,
3
+ * review §3.3 option 1).
4
+ *
5
+ * prepare writes `.release-skill/FROZEN` after a plan is frozen; verify
6
+ * clears it when the release reaches VERIFIED. The marker is a read-only
7
+ * governance signal — a gentlemen's-agreement flag that cross-repo writers
8
+ * (e.g. skill-family治理 tasks) may consult to avoid writing into a repo
9
+ * mid-release. This repo only maintains it mechanically; it does not and
10
+ * cannot enforce the convention on other writers.
11
+ *
12
+ * Marker content (JSON):
13
+ * { planDigest, targetVersions: { unitId: version }, createdAt, runId }
14
+ *
15
+ * Semantics:
16
+ * - A successful prepare overwrites any previous marker.
17
+ * - A failed prepare never writes (prepare only calls this after plan write).
18
+ * - verify clears it ONLY on VERIFIED; failed/PARTIAL verify runs keep it.
19
+ *
20
+ * @module core/frozen-marker
21
+ */
22
+
23
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+
26
+ /** Marker file name inside the `.release-skill` release directory. */
27
+ export const FROZEN_MARKER_FILENAME = 'FROZEN';
28
+
29
+ /**
30
+ * Absolute marker path for a release directory.
31
+ *
32
+ * @param {string} releaseDir - Absolute `.release-skill` directory.
33
+ * @returns {string}
34
+ */
35
+ export function frozenMarkerPath(releaseDir) {
36
+ return join(releaseDir, FROZEN_MARKER_FILENAME);
37
+ }
38
+
39
+ /**
40
+ * Write (or overwrite) the FROZEN marker.
41
+ *
42
+ * @param {string} releaseDir - Absolute `.release-skill` directory.
43
+ * @param {object} marker
44
+ * @param {string} marker.planDigest - Digest of the frozen plan.
45
+ * @param {Record<string, string>} marker.targetVersions - unitId -> version.
46
+ * @param {string} marker.createdAt - ISO-8601 timestamp (plan createdAt).
47
+ * @param {string} marker.runId - The prepare run id that froze the plan.
48
+ * @returns {Promise<string>} The marker path written.
49
+ */
50
+ export async function writeFrozenMarker(releaseDir, marker) {
51
+ const payload = {
52
+ planDigest: marker.planDigest,
53
+ targetVersions: marker.targetVersions,
54
+ createdAt: marker.createdAt,
55
+ runId: marker.runId,
56
+ };
57
+ const markerPath = frozenMarkerPath(releaseDir);
58
+ await writeFile(markerPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
59
+ return markerPath;
60
+ }
61
+
62
+ /**
63
+ * Read the FROZEN marker without throwing.
64
+ *
65
+ * @param {string} releaseDir - Absolute `.release-skill` directory.
66
+ * @returns {Promise<object | null>} The marker payload, or null when absent
67
+ * or unparseable (a corrupt marker is treated as absent).
68
+ */
69
+ export async function readFrozenMarker(releaseDir) {
70
+ let raw;
71
+ try {
72
+ raw = await readFile(frozenMarkerPath(releaseDir), 'utf8');
73
+ } catch {
74
+ return null;
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(raw);
78
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Clear the FROZEN marker.
86
+ *
87
+ * @param {string} releaseDir - Absolute `.release-skill` directory.
88
+ * @returns {Promise<boolean>} true when a marker existed and was removed.
89
+ */
90
+ export async function clearFrozenMarker(releaseDir) {
91
+ try {
92
+ await unlink(frozenMarkerPath(releaseDir));
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
@@ -54,7 +54,7 @@ function validateHook(hook) {
54
54
  throw new ReleaseError('INVALID_HOOK', 'hook must be a non-null object');
55
55
  }
56
56
 
57
- const { command, cwd, timeoutMs, envAllowlist, cacheable, cacheInputs } = hook;
57
+ const { command, cwd, timeoutMs, envAllowlist, cacheable, cacheInputs, testSelection } = hook;
58
58
 
59
59
  // command: required, non-empty array of strings
60
60
  if (!Array.isArray(command) || command.length === 0) {
@@ -126,6 +126,17 @@ function validateHook(hook) {
126
126
  'hook.cacheable=true requires a non-empty hook.cacheInputs',
127
127
  );
128
128
  }
129
+
130
+ // testSelection: optional enum declaring which test selection the hook
131
+ // command runs (2026-08-18 investigation §4.4 full-test freeze gate).
132
+ // Only meaningful for the test hook; prepare rejects 'incremental' at
133
+ // freeze time. Mirrors the formal schema enum.
134
+ if (testSelection !== undefined && testSelection !== 'full' && testSelection !== 'incremental') {
135
+ throw new ReleaseError(
136
+ 'INVALID_HOOK',
137
+ 'hook.testSelection must be "full" or "incremental" when provided',
138
+ );
139
+ }
129
140
  }
130
141
 
131
142
  // ---------------------------------------------------------------------------