release-skill 0.1.10 → 0.2.0

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