release-skill 0.1.10 → 0.2.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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +33 -0
  7. package/INSTALL.md +24 -4
  8. package/INSTALL.zh-CN.md +22 -4
  9. package/README.md +19 -32
  10. package/README.zh-CN.md +14 -25
  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 +2721 -1843
  14. package/adapters/claude/schemas/.render-manifest.json +8 -8
  15. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  16. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  17. package/adapters/claude/schemas/release-project.schema.json +14 -0
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
  20. package/adapters/codex/schemas/.render-manifest.json +8 -8
  21. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  22. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  23. package/adapters/codex/schemas/release-project.schema.json +14 -0
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
  26. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  27. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  28. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  29. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +10 -0
  31. package/adapters/workbuddy/bin/release-skill.bundle.mjs +85363 -0
  32. package/adapters/workbuddy/bin/release-skill.mjs +54 -0
  33. package/adapters/workbuddy/native/safe-write/binding.gyp +41 -0
  34. package/adapters/workbuddy/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  35. package/adapters/workbuddy/native/safe-write/prebuilds.json +24 -0
  36. package/adapters/workbuddy/native/safe-write/src/safe_write.cc +2032 -0
  37. package/adapters/workbuddy/schemas/.render-manifest.json +37 -0
  38. package/adapters/workbuddy/schemas/approval-record.schema.json +115 -0
  39. package/adapters/workbuddy/schemas/artifact-lock.schema.json +111 -0
  40. package/adapters/workbuddy/schemas/artifact-plan.schema.json +52 -0
  41. package/adapters/workbuddy/schemas/artifact-policy.schema.json +76 -0
  42. package/adapters/workbuddy/schemas/evidence-event.schema.json +89 -0
  43. package/adapters/workbuddy/schemas/release-plan.schema.json +882 -0
  44. package/adapters/workbuddy/schemas/release-project.schema.json +909 -0
  45. package/adapters/workbuddy/schemas/release-run.schema.json +343 -0
  46. package/adapters/workbuddy/skills/release-assess/SKILL.md +51 -0
  47. package/adapters/workbuddy/skills/release-help/SKILL.md +77 -0
  48. package/adapters/workbuddy/skills/release-prepare/SKILL.md +92 -0
  49. package/adapters/workbuddy/skills/release-publish/SKILL.md +57 -0
  50. package/adapters/workbuddy/skills/release-reconcile/SKILL.md +73 -0
  51. package/adapters/workbuddy/skills/release-setup/SKILL.md +95 -0
  52. package/adapters/workbuddy/skills/release-verify/SKILL.md +70 -0
  53. package/bin/release-skill-cli.mjs +3 -0
  54. package/bin/release-skill.bundle.mjs +2721 -1843
  55. package/package.json +9 -2
  56. package/references/.render-manifest.json +8 -8
  57. package/references/01-state-machine.md +5 -5
  58. package/references/02-project-config.md +1 -1
  59. package/references/05-evidence-and-errors.md +1 -1
  60. package/references/06-adapter-contract.md +41 -1
  61. package/schemas/.render-manifest.json +8 -8
  62. package/schemas/approval-record.schema.json +1 -1
  63. package/schemas/release-plan.schema.json +6 -2
  64. package/schemas/release-project.schema.json +14 -0
  65. package/scripts/sync-public-files.mjs +481 -0
  66. package/src/adapters/contract.mjs +60 -0
  67. package/src/adapters/plugin-marketplace.mjs +289 -736
  68. package/src/commands/prepare.mjs +195 -182
  69. package/src/commands/publish.mjs +438 -122
  70. package/src/commands/reconcile.mjs +369 -191
  71. package/src/commands/verify.mjs +13 -2
  72. package/src/core/approval.mjs +72 -45
  73. package/src/core/baseline.mjs +5 -0
  74. package/src/core/checkpoints.mjs +143 -0
  75. package/src/core/evidence.mjs +30 -3
  76. package/src/core/hook-cache.mjs +254 -0
  77. package/src/core/hooks.mjs +37 -1
  78. package/src/core/observe-retry.mjs +223 -0
  79. package/src/core/plan.mjs +162 -253
  80. package/src/platforms/kimi.mjs +514 -0
  81. package/src/platforms/registry.mjs +393 -0
  82. package/src/producers/build-adapters.mjs +49 -23
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-public-files.mjs
4
+ *
5
+ * Generate the `publicFiles` and `requiredPublicFiles` arrays of
6
+ * `.release-skill/project.yaml` from the platform registry and the actual
7
+ * package directory contents. This replaces the hand-maintained ~600-line
8
+ * file lists with a registry-driven generator.
9
+ *
10
+ * The generator scans `packages/release-skill/` for all regular files,
11
+ * excludes the `adapters/` subtree (generated by build-adapters), then adds:
12
+ * 1. Platform manifest files derived from the registry (manifestPaths +
13
+ * buildAdapter fields), plus the root manifests of build-only
14
+ * distribution adapters (BUILD_ONLY_ADAPTERS, e.g. workbuddy).
15
+ * 2. All files under `adapters/{platform.id}/` for each registry platform
16
+ * and each build-only adapter.
17
+ *
18
+ * Usage (run from the package directory):
19
+ * node scripts/sync-public-files.mjs # rewrite drifted arrays
20
+ * node scripts/sync-public-files.mjs --check # read-only; exit 1 on drift
21
+ *
22
+ * Zero runtime dependencies; Node.js standard library only.
23
+ */
24
+
25
+ import { readFile, writeFile, readdir, stat, realpath } from 'node:fs/promises';
26
+ import { join, relative } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Platform registry import
31
+ // ---------------------------------------------------------------------------
32
+
33
+ import { PLATFORMS } from '../src/platforms/registry.mjs';
34
+ import { BUILD_ONLY_ADAPTERS } from '../src/producers/build-adapters.mjs';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Constants
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const SCRIPT_DIR = new URL('.', import.meta.url).pathname;
41
+ const PKG_ROOT = process.env.RELEASE_SKILL_PKG_ROOT ?? join(SCRIPT_DIR, '..');
42
+ const PROJECT_YAML = join(PKG_ROOT, '..', '..', '.release-skill', 'project.yaml');
43
+ const SOURCE_PREFIX = 'packages/release-skill/';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // File scanning
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Recursively collect all regular file paths (relative to `baseDir`) under
51
+ * `baseDir`, sorted lexicographically.
52
+ */
53
+ async function scanFiles(baseDir, relPrefix = '') {
54
+ const results = [];
55
+ let entries;
56
+ try {
57
+ entries = await readdir(baseDir, { withFileTypes: true });
58
+ } catch {
59
+ return results;
60
+ }
61
+ for (const entry of entries) {
62
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
63
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
64
+ if (entry.isDirectory()) {
65
+ results.push(...await scanFiles(join(baseDir, entry.name), relPath));
66
+ } else if (entry.isFile()) {
67
+ results.push(relPath);
68
+ }
69
+ }
70
+ return results;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Platform manifest path derivation
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Derive all manifest file paths for a platform from the registry.
79
+ * Returns paths relative to the package root.
80
+ */
81
+ function deriveManifestPaths(platform) {
82
+ const paths = [];
83
+ const mp = platform.manifestPaths;
84
+
85
+ // plugin manifest
86
+ if (mp.plugin && typeof mp.plugin === 'string') {
87
+ paths.push(mp.plugin);
88
+ }
89
+ // kimi has pluginCandidates (array) instead of a single plugin path
90
+ if (Array.isArray(mp.pluginCandidates)) {
91
+ for (const candidate of mp.pluginCandidates) {
92
+ paths.push(candidate);
93
+ }
94
+ }
95
+ // marketplace manifest (codex root marketplace)
96
+ if (mp.marketplace && typeof mp.marketplace === 'string') {
97
+ paths.push(mp.marketplace);
98
+ }
99
+
100
+ return paths;
101
+ }
102
+
103
+ /**
104
+ * Derive all adapter-relative file paths for a platform from the registry.
105
+ * These are the static structure inside adapters/{name}/ that is NOT generated
106
+ * by build-adapters (i.e., plugin manifests). The rest of the adapter
107
+ * directory is scanned from disk.
108
+ */
109
+ function deriveAdapterManifestPaths(platform) {
110
+ const paths = [];
111
+ const ba = platform.buildAdapter;
112
+
113
+ // Plugin manifest directory
114
+ if (ba.pluginDirName) {
115
+ paths.push(`${ba.pluginDirName}/${ba.templateFileName}`);
116
+ if (ba.marketplaceFileName) {
117
+ paths.push(`${ba.pluginDirName}/${ba.marketplaceFileName}`);
118
+ }
119
+ }
120
+
121
+ return paths;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // YAML manipulation
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Find the line range of a YAML top-level array property.
130
+ * Returns { start, end } (0-indexed, inclusive) or null if not found.
131
+ * `start` is the line of the property key; `end` is the last line of the array.
132
+ */
133
+ function findArrayRange(lines, key, parentIndent = 4) {
134
+ const keyLine = `${' '.repeat(parentIndent)}${key}:`;
135
+ let start = -1;
136
+ for (let i = 0; i < lines.length; i++) {
137
+ if (lines[i] === keyLine || lines[i] === `${keyLine} []`) {
138
+ start = i;
139
+ break;
140
+ }
141
+ }
142
+ if (start < 0) return null;
143
+
144
+ // If the key line ends with `[]`, the array is empty and on the same line.
145
+ if (lines[start].endsWith('[]')) {
146
+ return { start, end: start };
147
+ }
148
+
149
+ // Find the end of the array: the last line that is indented more than the
150
+ // key line, or is a `- ` item at the array indentation level.
151
+ const arrayIndent = parentIndent + 2; // typical: key at 4, items at 6
152
+ let end = start;
153
+ for (let i = start + 1; i < lines.length; i++) {
154
+ const line = lines[i];
155
+ // Empty lines within the array
156
+ if (line.trim() === '') {
157
+ // Check if next non-empty line is still part of the array
158
+ let nextNonEmpty = i + 1;
159
+ while (nextNonEmpty < lines.length && lines[nextNonEmpty].trim() === '') {
160
+ nextNonEmpty++;
161
+ }
162
+ if (nextNonEmpty < lines.length && isIndentedAtLeast(lines[nextNonEmpty], arrayIndent)) {
163
+ end = i;
164
+ continue;
165
+ }
166
+ break;
167
+ }
168
+ if (isIndentedAtLeast(line, arrayIndent)) {
169
+ end = i;
170
+ } else {
171
+ break;
172
+ }
173
+ }
174
+ return { start, end };
175
+ }
176
+
177
+ function isIndentedAtLeast(line, indent) {
178
+ if (line.trim() === '') return false;
179
+ let count = 0;
180
+ for (const ch of line) {
181
+ if (ch === ' ') count++;
182
+ else break;
183
+ }
184
+ return count >= indent;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // YAML generation
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function buildPublicFilesYaml(entries, indent = 6) {
192
+ const prefix = ' '.repeat(indent);
193
+ const lines = [];
194
+ for (const entry of entries) {
195
+ lines.push(`${prefix}- from: ${SOURCE_PREFIX}${entry}`);
196
+ lines.push(`${prefix} to: ${entry}`);
197
+ lines.push(`${prefix} mode: preserve`);
198
+ }
199
+ return lines.join('\n');
200
+ }
201
+
202
+ function buildRequiredPublicFilesYaml(entries, indent = 6) {
203
+ const prefix = ' '.repeat(indent);
204
+ return entries.map((e) => `${prefix}- ${e}`).join('\n');
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Entry collection
209
+ // ---------------------------------------------------------------------------
210
+
211
+ async function collectEntries() {
212
+ // 1. Scan all files in the package directory
213
+ const allFiles = await scanFiles(PKG_ROOT);
214
+ const allSet = new Set(allFiles);
215
+
216
+ // 2. Build the adapter exclusion set (all files under adapters/)
217
+ const adapterFiles = new Set();
218
+ for (const f of allFiles) {
219
+ if (f.startsWith('adapters/')) {
220
+ adapterFiles.add(f);
221
+ }
222
+ }
223
+
224
+ // 3. Base files = all files except adapters/ and non-public directories/files.
225
+ // Excluded:
226
+ // - test/ (test files, not shipped)
227
+ // - test-fixtures/ (test fixtures, not shipped)
228
+ // - release-notes/ (consumed by docs-refresh, not a public artifact)
229
+ // - native/*/build/ (native build artifacts, only prebuilds are shipped)
230
+ // - scripts/* (mostly dev-only; explicit whitelist below)
231
+ const baseFiles = allFiles.filter((f) => {
232
+ if (f.startsWith('adapters/')) return false;
233
+ if (f.startsWith('test/')) return false;
234
+ if (f.startsWith('test-fixtures/')) return false;
235
+ if (f.startsWith('release-notes/')) return false;
236
+ if (f.startsWith('native/') && f.includes('/build/')) return false;
237
+ if (f.startsWith('scripts/')) return false;
238
+ return true;
239
+ });
240
+
241
+ // 4. Public scripts: explicit whitelist (most scripts are dev-only).
242
+ const PUBLIC_SCRIPTS = Object.freeze([
243
+ 'scripts/build-bundle.mjs',
244
+ 'scripts/sync-public-files.mjs',
245
+ ]);
246
+ for (const script of PUBLIC_SCRIPTS) {
247
+ if (allSet.has(script)) {
248
+ baseFiles.push(script);
249
+ }
250
+ }
251
+
252
+ // 4. Collect platform-specific files
253
+ const platformManifestFiles = [];
254
+ const platformAdapterFiles = [];
255
+
256
+ for (const platform of PLATFORMS) {
257
+ // Root-level manifest files
258
+ for (const mp of deriveManifestPaths(platform)) {
259
+ if (allSet.has(mp)) {
260
+ platformManifestFiles.push(mp);
261
+ }
262
+ }
263
+
264
+ // Adapter directory files
265
+ const adapterDir = `adapters/${platform.id}`;
266
+ const adapterDirPrefix = `${adapterDir}/`;
267
+ for (const f of allFiles) {
268
+ if (f.startsWith(adapterDirPrefix)) {
269
+ platformAdapterFiles.push(f);
270
+ }
271
+ }
272
+ }
273
+
274
+ // Build-only distribution adapters (e.g. workbuddy): not publish-pipeline
275
+ // platforms, but their generated adapter trees and root manifests ship
276
+ // publicly just like the pipeline platforms'.
277
+ for (const adapter of BUILD_ONLY_ADAPTERS) {
278
+ const rootManifest = `${adapter.pluginDirName}/${adapter.templateFileName}`;
279
+ if (allSet.has(rootManifest)) {
280
+ platformManifestFiles.push(rootManifest);
281
+ }
282
+ const adapterDirPrefix = `adapters/${adapter.name}/`;
283
+ for (const f of allFiles) {
284
+ if (f.startsWith(adapterDirPrefix)) {
285
+ platformAdapterFiles.push(f);
286
+ }
287
+ }
288
+ }
289
+
290
+ // 5. Combine, deduplicate, sort
291
+ const combined = [...baseFiles, ...platformManifestFiles, ...platformAdapterFiles];
292
+ const unique = [...new Set(combined)];
293
+ unique.sort();
294
+
295
+ return unique;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Check / write
300
+ // ---------------------------------------------------------------------------
301
+
302
+ async function readProjectYaml() {
303
+ return await readFile(PROJECT_YAML, 'utf8');
304
+ }
305
+
306
+ function extractExistingEntries(yamlText, key) {
307
+ const lines = yamlText.split('\n');
308
+ const range = findArrayRange(lines, key);
309
+ if (!range) return [];
310
+
311
+ if (range.start === range.end && lines[range.start].endsWith('[]')) {
312
+ return [];
313
+ }
314
+
315
+ const entries = [];
316
+ for (let i = range.start + 1; i <= range.end; i++) {
317
+ const line = lines[i];
318
+ // Match `- from: ...` pattern (publicFiles)
319
+ const fromMatch = line.match(/^ +- from: (.+)$/);
320
+ if (fromMatch) {
321
+ // The `to` is on the next line
322
+ for (let j = i + 1; j <= Math.min(i + 2, range.end); j++) {
323
+ const toMatch = lines[j].match(/^ +to: (.+)$/);
324
+ if (toMatch) {
325
+ entries.push(toMatch[1]);
326
+ break;
327
+ }
328
+ }
329
+ continue;
330
+ }
331
+ // Match `- <value>` pattern (requiredPublicFiles)
332
+ const simpleMatch = line.match(/^ +- (.+)$/);
333
+ if (simpleMatch) {
334
+ entries.push(simpleMatch[1]);
335
+ }
336
+ }
337
+ return entries;
338
+ }
339
+
340
+ function replaceArrayInYaml(yamlText, key, newContent) {
341
+ const lines = yamlText.split('\n');
342
+ const range = findArrayRange(lines, key);
343
+ if (!range) {
344
+ throw new Error(`sync-public-files: cannot find "${key}" in project.yaml`);
345
+ }
346
+
347
+ // Replace the array content (everything after the key line through the end)
348
+ const before = lines.slice(0, range.start + 1);
349
+ const after = lines.slice(range.end + 1);
350
+
351
+ // If newContent is empty, use `[]` notation
352
+ if (newContent.trim() === '') {
353
+ return [...before.map((l) => l), `${' '.repeat(4)}${key}: []`, ...after].join('\n');
354
+ }
355
+
356
+ return [...before, newContent, ...after].join('\n');
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Public API
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Sync the publicFiles and requiredPublicFiles arrays in project.yaml.
365
+ *
366
+ * @param {object} options
367
+ * @param {string} options.packageDir - Package directory.
368
+ * @param {boolean} [options.check] - Read-only drift detection.
369
+ * @returns {Promise<{mode: string, clean?: boolean, diff?: object}>}
370
+ */
371
+ export async function syncPublicFiles({ packageDir, check = false } = {}) {
372
+ if (typeof packageDir !== 'string' || packageDir.length === 0) {
373
+ const err = new Error('sync-public-files: packageDir is required');
374
+ err.code = 'SYNC_PUBLIC_FILES_INVALID_ARGUMENT';
375
+ throw err;
376
+ }
377
+
378
+ const entries = await collectEntries();
379
+ const yamlText = await readProjectYaml();
380
+
381
+ const existingPublic = extractExistingEntries(yamlText, 'publicFiles');
382
+ const existingRequired = extractExistingEntries(yamlText, 'requiredPublicFiles');
383
+
384
+ // Compare sets (order-independent)
385
+ const publicSet = new Set(entries);
386
+ const existingPublicSet = new Set(existingPublic);
387
+ const requiredSet = new Set(entries);
388
+ const existingRequiredSet = new Set(existingRequired);
389
+
390
+ const publicMissing = entries.filter((e) => !existingPublicSet.has(e));
391
+ const publicExtra = existingPublic.filter((e) => !publicSet.has(e));
392
+ const requiredMissing = entries.filter((e) => !existingRequiredSet.has(e));
393
+ const requiredExtra = existingRequired.filter((e) => !requiredSet.has(e));
394
+
395
+ const isClean = publicMissing.length === 0 && publicExtra.length === 0
396
+ && requiredMissing.length === 0 && requiredExtra.length === 0;
397
+
398
+ if (check) {
399
+ return {
400
+ mode: 'check',
401
+ entryCount: entries.length,
402
+ clean: isClean,
403
+ diff: isClean ? undefined : {
404
+ publicFiles: { missing: publicMissing, extra: publicExtra },
405
+ requiredPublicFiles: { missing: requiredMissing, extra: requiredExtra },
406
+ },
407
+ };
408
+ }
409
+
410
+ if (isClean) {
411
+ return { mode: 'write', entryCount: entries.length, changed: false };
412
+ }
413
+
414
+ // Generate new YAML content
415
+ const publicYaml = buildPublicFilesYaml(entries);
416
+ const requiredYaml = buildRequiredPublicFilesYaml(entries);
417
+
418
+ let newYaml = replaceArrayInYaml(yamlText, 'publicFiles', publicYaml);
419
+ newYaml = replaceArrayInYaml(newYaml, 'requiredPublicFiles', requiredYaml);
420
+
421
+ await writeFile(PROJECT_YAML, newYaml, 'utf8');
422
+
423
+ // Verify write
424
+ const reread = await readFile(PROJECT_YAML, 'utf8');
425
+ if (reread !== newYaml) {
426
+ const err = new Error('sync-public-files: post-write verification failed');
427
+ err.code = 'SYNC_PUBLIC_FILES_WRITE_VERIFY_FAILED';
428
+ throw err;
429
+ }
430
+
431
+ return { mode: 'write', entryCount: entries.length, changed: true };
432
+ }
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // CLI
436
+ // ---------------------------------------------------------------------------
437
+
438
+ const invokedPath = process.argv[1]
439
+ ? await realpath(process.argv[1]).catch(() => process.argv[1])
440
+ : '';
441
+ const isMain = invokedPath.length > 0 && fileURLToPath(import.meta.url) === invokedPath;
442
+
443
+ if (isMain) {
444
+ const checkMode = process.argv.includes('--check');
445
+ try {
446
+ const result = await syncPublicFiles({ packageDir: PKG_ROOT, check: checkMode });
447
+ if (checkMode) {
448
+ if (result.clean) {
449
+ console.log(`[sync-public-files] OK: all ${result.entryCount} entries in sync.`);
450
+ } else {
451
+ console.error(`[sync-public-files] drift detected:`);
452
+ const d = result.diff;
453
+ if (d.publicFiles.missing.length > 0) {
454
+ console.error(` publicFiles missing (${d.publicFiles.missing.length}):`);
455
+ for (const e of d.publicFiles.missing) console.error(` + ${e}`);
456
+ }
457
+ if (d.publicFiles.extra.length > 0) {
458
+ console.error(` publicFiles extra (${d.publicFiles.extra.length}):`);
459
+ for (const e of d.publicFiles.extra) console.error(` - ${e}`);
460
+ }
461
+ if (d.requiredPublicFiles.missing.length > 0) {
462
+ console.error(` requiredPublicFiles missing (${d.requiredPublicFiles.missing.length}):`);
463
+ for (const e of d.requiredPublicFiles.missing) console.error(` + ${e}`);
464
+ }
465
+ if (d.requiredPublicFiles.extra.length > 0) {
466
+ console.error(` requiredPublicFiles extra (${d.requiredPublicFiles.extra.length}):`);
467
+ for (const e of d.requiredPublicFiles.extra) console.error(` - ${e}`);
468
+ }
469
+ console.error('Run "node scripts/sync-public-files.mjs" from the package directory to sync.');
470
+ process.exit(1);
471
+ }
472
+ } else if (!result.changed) {
473
+ console.log(`[sync-public-files] already in sync (${result.entryCount} entries).`);
474
+ } else {
475
+ console.log(`[sync-public-files] synced ${result.entryCount} entries.`);
476
+ }
477
+ } catch (error) {
478
+ console.error(error.message);
479
+ process.exit(1);
480
+ }
481
+ }
@@ -13,6 +13,8 @@
13
13
  * @module adapters/contract
14
14
  */
15
15
 
16
+ import { writeFile, rename, rm } from 'node:fs/promises';
17
+
16
18
  import { ReleaseError, AUTH_MISSING } from '../core/errors.mjs';
17
19
 
18
20
  /**
@@ -146,6 +148,64 @@ export function matchObservation(expected, observation) {
146
148
  return { matches: mismatches.length === 0, mismatches };
147
149
  }
148
150
 
151
+ /** Safe identifier pattern: lowercase alphanumeric, hyphens, dots, underscores. */
152
+ export const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
153
+
154
+ /**
155
+ * Resolve and validate the frozen timeoutMs from the expanded adapter action.
156
+ *
157
+ * The publish/reconcile/verify call path expands plan actions as
158
+ * `{ actionType, ...action.parameters }`, so `parameters.timeoutMs` in the
159
+ * plan becomes `action.timeoutMs` at the adapter level. This function reads
160
+ * from the top-level action, not from a nested `parameters` sub-object.
161
+ *
162
+ * Rules:
163
+ * - Missing field (undefined): returns 300000 default (legacy compatibility).
164
+ * - Present but null/invalid (null, string, NaN, Infinity, non-integer,
165
+ * out of range): fail-closed, throws.
166
+ * - Valid integer in [30000, 900000]: returns the value as-is.
167
+ *
168
+ * @param {object} action - The expanded adapter action (top-level).
169
+ * @returns {number} Validated timeout in milliseconds.
170
+ * @throws {Error} If the value is present but invalid.
171
+ */
172
+ export function resolveTimeoutMs(action) {
173
+ const raw = action?.timeoutMs;
174
+ if (raw === undefined) {
175
+ return 300000;
176
+ }
177
+ if (raw === null || typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
178
+ throw new Error(
179
+ `action.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
180
+ );
181
+ }
182
+ if (raw < 30000 || raw > 900000) {
183
+ throw new Error(
184
+ `action.timeoutMs must be between 30000 and 900000, got: ${raw}`,
185
+ );
186
+ }
187
+ return raw;
188
+ }
189
+
190
+ /**
191
+ * Atomically write a JSON evidence/requirement file (mode 0o600, exclusive
192
+ * create, rename into place). Crash-safe: a partial write never replaces an
193
+ * existing file.
194
+ *
195
+ * @param {string} filePath
196
+ * @param {object} value
197
+ */
198
+ export async function writeEvidenceAtomic(filePath, value) {
199
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
200
+ try {
201
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
202
+ await rename(tempPath, filePath);
203
+ } catch (err) {
204
+ await rm(tempPath, { force: true }).catch(() => {});
205
+ throw err;
206
+ }
207
+ }
208
+
149
209
  /**
150
210
  * Adapter interface type documentation (not enforced at runtime, but all
151
211
  * adapters must follow this shape):