release-skill 0.1.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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,76 @@
1
+ // Release state machine: guards and enforces valid state transitions.
2
+
3
+ import { ReleaseError, INVALID_STATE_TRANSITION } from './errors.mjs';
4
+
5
+ // ---- State constants ----
6
+
7
+ export const DISCOVERED = 'DISCOVERED';
8
+ export const ASSESSED = 'ASSESSED';
9
+ export const PREPARED = 'PREPARED';
10
+ export const APPROVED = 'APPROVED';
11
+ export const PUBLISHING = 'PUBLISHING';
12
+ export const PUBLISHED = 'PUBLISHED';
13
+ export const VERIFIED = 'VERIFIED';
14
+
15
+ export const NEEDS_INPUT = 'NEEDS_INPUT';
16
+ export const BLOCKED = 'BLOCKED';
17
+ export const PARTIAL = 'PARTIAL';
18
+
19
+ /** Normal lifecycle states in order. */
20
+ export const NORMAL_STATES = Object.freeze([
21
+ DISCOVERED, ASSESSED, PREPARED, APPROVED, PUBLISHING, PUBLISHED, VERIFIED,
22
+ ]);
23
+
24
+ /** Exception states. */
25
+ export const EXCEPTION_STATES = Object.freeze([
26
+ NEEDS_INPUT, BLOCKED, PARTIAL,
27
+ ]);
28
+
29
+ /** All valid states. */
30
+ export const ALL_STATES = Object.freeze([...NORMAL_STATES, ...EXCEPTION_STATES]);
31
+
32
+ // ---- Allowed transitions ----
33
+
34
+ /**
35
+ * Explicit transition map.
36
+ *
37
+ * Design rules:
38
+ * - Normal path only moves forward (single step).
39
+ * - NEEDS_INPUT and BLOCKED can be entered from any normal state except VERIFIED.
40
+ * - NEEDS_INPUT and BLOCKED can return to any normal state except VERIFIED.
41
+ * - PARTIAL can only be entered from PUBLISHING.
42
+ * - PARTIAL can return to PUBLISHING/PUBLISHED or escalate to NEEDS_INPUT / BLOCKED.
43
+ * - APPROVED cannot skip directly to VERIFIED.
44
+ * - NEEDS_INPUT and BLOCKED cannot transition to VERIFIED.
45
+ * - VERIFIED is terminal with no outbound transitions.
46
+ */
47
+ const TRANSITIONS = Object.freeze({
48
+ [DISCOVERED]: Object.freeze([ASSESSED, NEEDS_INPUT, BLOCKED]),
49
+ [ASSESSED]: Object.freeze([PREPARED, NEEDS_INPUT, BLOCKED]),
50
+ [PREPARED]: Object.freeze([APPROVED, NEEDS_INPUT, BLOCKED]),
51
+ [APPROVED]: Object.freeze([PUBLISHING, NEEDS_INPUT, BLOCKED]),
52
+ [PUBLISHING]: Object.freeze([PUBLISHED, PARTIAL, NEEDS_INPUT, BLOCKED]),
53
+ [PUBLISHED]: Object.freeze([VERIFIED, NEEDS_INPUT, BLOCKED]),
54
+ [VERIFIED]: Object.freeze([]),
55
+ [NEEDS_INPUT]: Object.freeze([ASSESSED, PREPARED, APPROVED, BLOCKED]),
56
+ [BLOCKED]: Object.freeze([ASSESSED, PREPARED, APPROVED, NEEDS_INPUT]),
57
+ [PARTIAL]: Object.freeze([PUBLISHING, PUBLISHED, NEEDS_INPUT, BLOCKED]),
58
+ });
59
+
60
+ /**
61
+ * Assert that a transition from `from` to `to` is allowed.
62
+ *
63
+ * @param {string} from Current state.
64
+ * @param {string} to Target state.
65
+ * @throws {ReleaseError} with code INVALID_STATE_TRANSITION if the transition is not allowed.
66
+ */
67
+ export function assertTransition(from, to) {
68
+ const allowed = TRANSITIONS[from];
69
+ if (!allowed || !allowed.includes(to)) {
70
+ throw new ReleaseError(
71
+ INVALID_STATE_TRANSITION,
72
+ `Invalid state transition: ${from} -> ${to}`,
73
+ { from, to },
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 版本一致性校验纯函数 — 供 blackbox runner 和单测使用。
3
+ *
4
+ * 给定 release plan JSON 和预期版本字符串,检查:
5
+ * 1. plan.status === 'PREPARED'
6
+ * 2. plan 至少有一个 release unit
7
+ * 3. 若指定 expectedUnitId,该 unit 必须存在且 targetVersion === expectedVersion
8
+ * 4. 每个 unit 的 targetVersion === expectedVersion
9
+ * 5. 每个带 parameters.version 的 action === expectedVersion
10
+ * 6. 每个带 expected.version 的 action === expectedVersion
11
+ * 7. 每个带 expected.tag 的 action:必须通过 unitId 找到 unit,
12
+ * unit 必须有合法 tagTemplate,然后只做模板展开后的精确相等;
13
+ * 否则失败。不做子串匹配。
14
+ *
15
+ * @param {object} plan - 解析后的 release plan JSON
16
+ * @param {string} expectedVersion - 预期版本号
17
+ * @param {object} [options] - 可选参数
18
+ * @param {string} [options.expectedUnitId] - 必须存在的 unit id
19
+ * @returns {{ passed: boolean, details: object }}
20
+ */
21
+ export function validateVersionConsistency(plan, expectedVersion, options = {}) {
22
+ const failures = [];
23
+
24
+ // 1. plan status
25
+ if (!plan || typeof plan !== 'object') {
26
+ return { passed: false, details: { failures: ['plan is null or not an object'], expectedVersion } };
27
+ }
28
+ if (plan.status !== 'PREPARED') {
29
+ failures.push(`plan.status is ${JSON.stringify(plan.status)}, expected PREPARED`);
30
+ }
31
+
32
+ // 2. plan must have at least one release unit (must be an array)
33
+ const rawUnits = plan.units;
34
+ const units = Array.isArray(rawUnits) ? rawUnits : [];
35
+ if (!Array.isArray(rawUnits)) {
36
+ failures.push(`plan.units is ${JSON.stringify(rawUnits)}, expected an array`);
37
+ }
38
+ if (units.length === 0) {
39
+ failures.push('plan has no release units; at least one unit is required');
40
+ }
41
+
42
+ // 3. expectedUnitId validation (if specified)
43
+ const { expectedUnitId } = options;
44
+ if (expectedUnitId) {
45
+ const found = units.find(u => u.id === expectedUnitId);
46
+ if (!found) {
47
+ failures.push(`expected unit "${expectedUnitId}" not found in plan units`);
48
+ } else if (found.targetVersion !== expectedVersion) {
49
+ failures.push(`expected unit "${expectedUnitId}" targetVersion is ${JSON.stringify(found.targetVersion)}, expected ${expectedVersion}`);
50
+ }
51
+ }
52
+
53
+ // 4. unit versions
54
+ for (const unit of units) {
55
+ if (unit.targetVersion !== expectedVersion) {
56
+ failures.push(`unit "${unit.id ?? '(unknown)'}" targetVersion is ${JSON.stringify(unit.targetVersion)}, expected ${expectedVersion}`);
57
+ }
58
+ }
59
+
60
+ // 5. action parameters.version & expected.version (externalActions must be an array)
61
+ const rawActions = plan.externalActions;
62
+ const actions = Array.isArray(rawActions) ? rawActions : [];
63
+ if (!Array.isArray(rawActions)) {
64
+ failures.push(`plan.externalActions is ${JSON.stringify(rawActions)}, expected an array`);
65
+ }
66
+
67
+ // Build unit -> tagTemplate map for exact tag validation
68
+ const unitTagTemplates = new Map();
69
+ for (const unit of units) {
70
+ if (unit.id && unit.tagTemplate) {
71
+ unitTagTemplates.set(unit.id, unit.tagTemplate);
72
+ }
73
+ }
74
+
75
+ for (const action of actions) {
76
+ if (action.parameters?.version != null && action.parameters.version !== expectedVersion) {
77
+ failures.push(`action "${action.id}" parameters.version is ${JSON.stringify(action.parameters.version)}, expected ${expectedVersion}`);
78
+ }
79
+ if (action.expected?.version != null && action.expected.version !== expectedVersion) {
80
+ failures.push(`action "${action.id}" expected.version is ${JSON.stringify(action.expected.version)}, expected ${expectedVersion}`);
81
+ }
82
+ // 7. tag must match exactly via template expansion — no substring matching.
83
+ const tag = action.expected?.tag;
84
+ if (tag) {
85
+ if (!action.unitId) {
86
+ failures.push(`action "${action.id}" has expected.tag but no unitId; unitId is required for tag validation`);
87
+ } else {
88
+ const tagTemplate = unitTagTemplates.get(action.unitId);
89
+ if (!tagTemplate) {
90
+ failures.push(`action "${action.id}" unit "${action.unitId}" has no tagTemplate; tagTemplate is required for tag validation`);
91
+ } else {
92
+ const expectedTag = tagTemplate.replace('{version}', expectedVersion);
93
+ if (tag !== expectedTag) {
94
+ failures.push(`action "${action.id}" expected.tag ${JSON.stringify(tag)} does not match expanded template ${JSON.stringify(expectedTag)} (exact match required)`);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ return {
102
+ passed: failures.length === 0,
103
+ details: {
104
+ expectedVersion,
105
+ expectedUnitId: expectedUnitId ?? null,
106
+ unitCount: units.length,
107
+ actionCount: actions.length,
108
+ failures,
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Pure producer: build adapters from skills and plugin templates.
3
+ *
4
+ * Reads skill metadata from skills-src/ and plugin.json templates,
5
+ * generates adapter directories for each platform (claude, codex).
6
+ *
7
+ * Deterministic: sorted directory enumeration, no timestamps, no randomness.
8
+ *
9
+ * @module producers/build-adapters
10
+ */
11
+
12
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { join, dirname } from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
17
+
18
+ /**
19
+ * Compute an implementation digest from the source bytes of this module.
20
+ *
21
+ * @param {Buffer[]} sourceBytes
22
+ * @returns {string}
23
+ */
24
+ export function computeBuildAdaptersDigest(sourceBytes) {
25
+ const h = createHash('sha256');
26
+ for (const buf of sourceBytes) h.update(buf);
27
+ h.update(`node:${process.version}`);
28
+ h.update('locale:en-US timezone:UTC');
29
+ return `sha256:${h.digest('hex')}`;
30
+ }
31
+
32
+ // Platform definitions (same as legacy script)
33
+ const PLATFORMS = [
34
+ {
35
+ name: 'claude',
36
+ pluginDirName: '.claude-plugin',
37
+ templateFileName: 'plugin.json',
38
+ marketplaceFileName: 'marketplace.json',
39
+ hasMarketplace: true,
40
+ },
41
+ {
42
+ name: 'codex',
43
+ pluginDirName: '.codex-plugin',
44
+ templateFileName: 'plugin.json',
45
+ hasMarketplace: false,
46
+ },
47
+ ];
48
+
49
+ /**
50
+ * Collect skill metadata from skills-src/.
51
+ *
52
+ * @param {string} srcDir - Absolute path to skills-src/.
53
+ * @returns {Promise<Array<{name:string,description:string,content:string}>>}
54
+ */
55
+ async function collectSkills(srcDir) {
56
+ const entries = await readdir(srcDir, { withFileTypes: true });
57
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
58
+ dirs.sort();
59
+
60
+ const skills = [];
61
+ for (const dirName of dirs) {
62
+ const skillMdPath = join(srcDir, dirName, 'SKILL.md');
63
+ let content;
64
+ try {
65
+ content = await readFile(skillMdPath, 'utf-8');
66
+ } catch (err) {
67
+ if (err.code === 'ENOENT') continue;
68
+ throw err;
69
+ }
70
+
71
+ // Extract description: first paragraph of the first ## section.
72
+ const lines = content.split('\n');
73
+ let inFirstSection = false;
74
+ let pastSectionHeader = false;
75
+ const descLines = [];
76
+ for (const line of lines) {
77
+ if (line.startsWith('## ')) {
78
+ if (inFirstSection) break;
79
+ inFirstSection = true;
80
+ continue;
81
+ }
82
+ if (inFirstSection && !pastSectionHeader) {
83
+ if (line.trim() === '') pastSectionHeader = true;
84
+ continue;
85
+ }
86
+ if (pastSectionHeader) {
87
+ if (line.trim() === '') break;
88
+ descLines.push(line);
89
+ }
90
+ }
91
+ const description = descLines.join('\n').trim();
92
+ skills.push({ name: dirName, description, content });
93
+ }
94
+ return skills;
95
+ }
96
+
97
+ /**
98
+ * Generate the full file tree for an adapter platform.
99
+ *
100
+ * @param {object} platform - Platform definition.
101
+ * @param {Array} skills - Skill metadata array.
102
+ * @param {object} templateJson - Parsed plugin.json template.
103
+ * @param {object|null} marketplaceTemplateJson - Parsed marketplace template.
104
+ * @returns {Promise<Array<{relPath:string,content:string}>>}
105
+ */
106
+ async function generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson = null) {
107
+ const files = [];
108
+
109
+ // Transform plugin.json: rewrite source paths
110
+ const adapted = JSON.parse(JSON.stringify(templateJson));
111
+
112
+ // Preserve platform-supported directory auto-discovery in generated adapters.
113
+ // Codex validation requires the canonical "skills" directory, and Claude
114
+ // also accepts the same plugin-root-relative directory contract.
115
+ if (typeof adapted.skills === 'string') {
116
+ adapted.skills = './skills/';
117
+ } else if (Array.isArray(adapted.skills)) {
118
+ for (const skill of adapted.skills) {
119
+ skill.source = `../skills/${skill.name}/SKILL.md`;
120
+ }
121
+ }
122
+
123
+ const pluginJsonContent = JSON.stringify(adapted, null, 2) + '\n';
124
+ files.push({
125
+ relPath: join(platform.pluginDirName, 'plugin.json'),
126
+ content: pluginJsonContent,
127
+ });
128
+
129
+ // Generate marketplace.json for platforms that need it
130
+ if (platform.hasMarketplace) {
131
+ const marketplace = JSON.parse(JSON.stringify(marketplaceTemplateJson ?? {}));
132
+ marketplace.name = adapted.name;
133
+ marketplace.description = adapted.description;
134
+ marketplace.owner = marketplace.owner ?? adapted.author;
135
+ marketplace.plugins = [{
136
+ ...(marketplace.plugins?.[0] ?? {}),
137
+ name: adapted.name,
138
+ source: './',
139
+ version: adapted.version,
140
+ description: adapted.description,
141
+ }];
142
+ const marketplaceContent = JSON.stringify(marketplace, null, 2) + '\n';
143
+ files.push({
144
+ relPath: join(platform.pluginDirName, 'marketplace.json'),
145
+ content: marketplaceContent,
146
+ });
147
+ }
148
+
149
+ // Copy SKILL.md files verbatim
150
+ for (const skill of skills) {
151
+ files.push({
152
+ relPath: join('skills', skill.name, 'SKILL.md'),
153
+ content: skill.content,
154
+ });
155
+ }
156
+
157
+ // Sort for deterministic output
158
+ files.sort((a, b) => a.relPath.localeCompare(b.relPath));
159
+ return files;
160
+ }
161
+
162
+ /**
163
+ * Pure producer function for building adapters.
164
+ *
165
+ * @param {object} options
166
+ * @param {string} [options.inputs] - Root directory path. Defaults to package root.
167
+ * @param {string} options.output - Output directory path.
168
+ * @param {string} [options.platformFilter] - Only generate for this platform name.
169
+ */
170
+ export async function produceBuildAdapters({ inputs, output, platformFilter } = {}) {
171
+ const defaultRoot = new URL('../..', import.meta.url).pathname;
172
+ const root = inputs ?? defaultRoot;
173
+ const srcDir = join(root, 'skills-src');
174
+
175
+ const skills = await collectSkills(srcDir);
176
+
177
+ const outputs = [];
178
+
179
+ for (const platform of PLATFORMS) {
180
+ if (platformFilter && platform.name !== platformFilter) continue;
181
+ const templatePath = join(root, platform.pluginDirName, platform.templateFileName);
182
+ const templateRaw = await readFile(templatePath, 'utf-8');
183
+ const templateJson = JSON.parse(templateRaw);
184
+ let marketplaceTemplateJson = null;
185
+ if (platform.hasMarketplace) {
186
+ try {
187
+ marketplaceTemplateJson = JSON.parse(
188
+ await readFile(join(root, platform.pluginDirName, platform.marketplaceFileName), 'utf-8'),
189
+ );
190
+ } catch (error) {
191
+ if (error.code !== 'ENOENT') throw error;
192
+ }
193
+ }
194
+
195
+ const files = await generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson);
196
+
197
+ for (const file of files) {
198
+ const dstPath = join(output, file.relPath);
199
+ await mkdir(dirname(dstPath), { recursive: true });
200
+ const content = Buffer.from(file.content, 'utf-8');
201
+ await writeFile(dstPath, content);
202
+
203
+ outputs.push(Object.freeze({
204
+ path: file.relPath,
205
+ type: 'blob',
206
+ mode: '100644',
207
+ content,
208
+ sha256: sha256Hex(content),
209
+ size: content.length,
210
+ }));
211
+ }
212
+ }
213
+
214
+ return Object.freeze({
215
+ outputs: Object.freeze(outputs),
216
+ outputManifestDigest: digestEntryOutputs(outputs),
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Compute manifest digest from output entries.
222
+ *
223
+ * @param {Array<{path:string,type:string,mode:string,sha256:string,size:number}>} entries
224
+ * @returns {string}
225
+ */
226
+ export function digestEntryOutputs(entries) {
227
+ const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
228
+ path, type, mode, size, sha256,
229
+ }));
230
+ return `sha256:${sha256Hex(canonicalJson(canonical))}`;
231
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Pure producer: render public assets from standards/ and schemas/.
3
+ *
4
+ * Deterministically renders authoritative standards and schemas into the
5
+ * public plugin directory. Normalizes line endings (LF), strips trailing
6
+ * whitespace, ensures trailing newline. No timestamps in output — time
7
+ * is only written to evidence.
8
+ *
9
+ * Deterministic: sorted file enumeration, content normalization, no randomness.
10
+ *
11
+ * @module producers/render-public-assets
12
+ */
13
+
14
+ import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
15
+ import { join, relative } from 'node:path';
16
+ import { createHash } from 'node:crypto';
17
+
18
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
19
+
20
+ /**
21
+ * Compute an implementation digest from the source bytes of this module.
22
+ *
23
+ * @param {Buffer[]} sourceBytes
24
+ * @returns {string}
25
+ */
26
+ export function computeRenderDigest(sourceBytes) {
27
+ const h = createHash('sha256');
28
+ for (const buf of sourceBytes) h.update(buf);
29
+ h.update(`node:${process.version}`);
30
+ h.update('locale:en-US timezone:UTC');
31
+ return `sha256:${h.digest('hex')}`;
32
+ }
33
+
34
+ /**
35
+ * Recursively collect all file paths under dirPath.
36
+ * Returns relative paths sorted by POSIX path for deterministic ordering.
37
+ */
38
+ async function collectFiles(dirPath, base) {
39
+ const result = [];
40
+ const entries = await readdir(dirPath, { withFileTypes: true });
41
+ for (const entry of entries) {
42
+ const abs = join(dirPath, entry.name);
43
+ if (entry.isDirectory()) {
44
+ result.push(...await collectFiles(abs, base));
45
+ } else if (entry.isFile()) {
46
+ result.push(relative(base, abs));
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+
52
+ /**
53
+ * Normalize line endings to LF, strip trailing whitespace per line,
54
+ * and ensure a single trailing newline.
55
+ */
56
+ function normalizeContent(buf) {
57
+ return buf.toString('utf8')
58
+ .replace(/\r\n/g, '\n')
59
+ .split('\n')
60
+ .map(line => line.trimEnd())
61
+ .join('\n')
62
+ .replace(/\n+$/, '\n');
63
+ }
64
+
65
+ /**
66
+ * Deterministically render all files from srcDir into outDir.
67
+ *
68
+ * @param {string} srcDir - Source directory.
69
+ * @param {string} outDir - Output directory.
70
+ * @returns {Promise<Array<{path:string,type:string,mode:string,content:Buffer,sha256:string,size:number}>>}
71
+ */
72
+ async function renderDir(srcDir, outDir, pathPrefix) {
73
+ await mkdir(outDir, { recursive: true });
74
+
75
+ const files = await collectFiles(srcDir, srcDir);
76
+ files.sort();
77
+
78
+ const outputs = [];
79
+ for (const rel of files) {
80
+ const destPath = join(outDir, rel);
81
+ await mkdir(join(destPath, '..'), { recursive: true });
82
+
83
+ const raw = await readFile(join(srcDir, rel));
84
+ const contentStr = normalizeContent(raw);
85
+ const content = Buffer.from(contentStr, 'utf8');
86
+
87
+ await writeFile(destPath, content, 'utf8');
88
+ outputs.push(Object.freeze({
89
+ path: pathPrefix ? `${pathPrefix}/${rel}` : rel,
90
+ type: 'blob',
91
+ mode: '100644',
92
+ content,
93
+ sha256: sha256Hex(content),
94
+ size: content.length,
95
+ }));
96
+ }
97
+
98
+ return outputs;
99
+ }
100
+
101
+ /**
102
+ * Pure producer function for rendering public assets.
103
+ *
104
+ * @param {object} options
105
+ * @param {string} [options.inputs] - Root directory containing standards/ and schemas/.
106
+ * @param {string} options.output - Output base directory (references/ and schemas/ are created under it).
107
+ * @returns {Promise<{outputs: Array, outputManifestDigest: string}>}
108
+ */
109
+ export async function produceRenderPublicAssets({ inputs, output } = {}) {
110
+ const defaultRoot = new URL('../../..', import.meta.url).pathname;
111
+ const root = inputs ?? defaultRoot;
112
+
113
+ const srcDirs = [
114
+ { src: join(root, 'standards'), out: join(output, 'references'), label: 'standards', prefix: 'references' },
115
+ { src: join(root, 'schemas'), out: join(output, 'schemas'), label: 'schemas', prefix: 'schemas' },
116
+ ];
117
+
118
+ const allOutputs = [];
119
+
120
+ for (const { src, out, prefix } of srcDirs) {
121
+ let dirExists = false;
122
+ try {
123
+ const s = await stat(src);
124
+ dirExists = s.isDirectory();
125
+ } catch {
126
+ dirExists = false;
127
+ }
128
+
129
+ if (!dirExists) continue;
130
+
131
+ const outputs = await renderDir(src, out, prefix);
132
+ allOutputs.push(...outputs);
133
+ }
134
+
135
+ return Object.freeze({
136
+ outputs: Object.freeze(allOutputs),
137
+ outputManifestDigest: digestEntryOutputs(allOutputs),
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Compute manifest digest from output entries.
143
+ *
144
+ * @param {Array<{path:string,type:string,mode:string,sha256:string,size:number}>} entries
145
+ * @returns {string}
146
+ */
147
+ export function digestEntryOutputs(entries) {
148
+ const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
149
+ path, type, mode, size, sha256,
150
+ }));
151
+ return `sha256:${sha256Hex(canonicalJson(canonical))}`;
152
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure producer: sync skills from skills-src/ to skills/.
3
+ *
4
+ * Reads each subdirectory in the source path, copies SKILL.md files
5
+ * to the output directory. No side effects beyond the output directory.
6
+ *
7
+ * Deterministic: sorted directory enumeration, no timestamps, no randomness.
8
+ *
9
+ * @module producers/sync-skills
10
+ */
11
+
12
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
17
+
18
+ /**
19
+ * Compute an implementation digest from the source bytes of this module.
20
+ *
21
+ * @param {Buffer[]} sourceBytes
22
+ * @returns {string}
23
+ */
24
+ export function computeSyncSkillsDigest(sourceBytes) {
25
+ const h = createHash('sha256');
26
+ for (const buf of sourceBytes) h.update(buf);
27
+ h.update(`node:${process.version}`);
28
+ h.update('locale:en-US timezone:UTC');
29
+ return `sha256:${h.digest('hex')}`;
30
+ }
31
+
32
+ /**
33
+ * Pure producer function for syncing skills.
34
+ *
35
+ * Reads subdirectories from `inputs` (or default skills-src/),
36
+ * copies SKILL.md files to `output`.
37
+ *
38
+ * @param {object} options
39
+ * @param {string} [options.inputs] - Source directory path. Defaults to skills-src/ relative to module.
40
+ * @param {string} options.output - Output directory path.
41
+ */
42
+ export async function produceSyncSkills({ inputs, output } = {}) {
43
+ const defaultSrc = new URL('../../skills-src', import.meta.url).pathname;
44
+ const srcDir = inputs ?? defaultSrc;
45
+
46
+ const skillDirs = [];
47
+ for (const item of await readdir(srcDir, { withFileTypes: true })) {
48
+ if (item.isDirectory()) skillDirs.push(item.name);
49
+ }
50
+ skillDirs.sort();
51
+
52
+ const outputs = [];
53
+
54
+ for (const dirName of skillDirs) {
55
+ const srcFile = join(srcDir, dirName, 'SKILL.md');
56
+ let content;
57
+ try {
58
+ content = await readFile(srcFile);
59
+ } catch (err) {
60
+ if (err.code === 'ENOENT') continue;
61
+ throw err;
62
+ }
63
+
64
+ const dstDir = join(output, 'skills', dirName);
65
+ await mkdir(dstDir, { recursive: true });
66
+ const dstFile = join(dstDir, 'SKILL.md');
67
+ await writeFile(dstFile, content);
68
+
69
+ outputs.push(Object.freeze({
70
+ path: `skills/${dirName}/SKILL.md`,
71
+ type: 'blob',
72
+ mode: '100644',
73
+ content,
74
+ sha256: sha256Hex(content),
75
+ size: content.length,
76
+ }));
77
+ }
78
+
79
+ return Object.freeze({
80
+ outputs: Object.freeze(outputs),
81
+ outputManifestDigest: digestEntryOutputs(outputs),
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Compute manifest digest from output entries.
87
+ *
88
+ * @param {Array<{path:string,type:string,mode:string,sha256:string,size:number}>} entries
89
+ * @returns {string}
90
+ */
91
+ export function digestEntryOutputs(entries) {
92
+ const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
93
+ path, type, mode, size, sha256,
94
+ }));
95
+ return `sha256:${sha256Hex(canonicalJson(canonical))}`;
96
+ }