release-skill 0.9.23 → 0.9.25

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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +51 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +19 -32
  11. package/README.zh-CN.md +18 -28
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +840 -1030
  14. package/adapters/claude/skills/release-help/SKILL.md +2 -2
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +840 -1030
  17. package/adapters/codex/skills/release-help/SKILL.md +2 -2
  18. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  19. package/adapters/cursor/bin/release-skill.bundle.mjs +840 -1030
  20. package/adapters/cursor/skills/release-help/SKILL.md +2 -2
  21. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  22. package/adapters/kimi/bin/release-skill.bundle.mjs +840 -1030
  23. package/adapters/kimi/skills/release-help/SKILL.md +2 -2
  24. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  25. package/adapters/qoder/bin/release-skill.bundle.mjs +840 -1030
  26. package/adapters/qoder/skills/release-help/SKILL.md +2 -2
  27. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  28. package/adapters/workbuddy/bin/release-skill.bundle.mjs +840 -1030
  29. package/adapters/workbuddy/skills/release-help/SKILL.md +2 -2
  30. package/bin/release-skill.bundle.mjs +840 -1030
  31. package/package.json +1 -1
  32. package/platform-manifest.json +4 -4
  33. package/references/05-evidence-and-errors.md +2 -2
  34. package/scripts/build-bundle.mjs +2 -1
  35. package/scripts/sync-public-files.mjs +1 -1
  36. package/skills/release-help/SKILL.md +2 -2
  37. package/skills-src/release-help/SKILL.md +2 -2
  38. package/src/artifacts/producer-registry.mjs +4 -3
  39. package/src/commands/prepare.mjs +5 -30
  40. package/src/core/baseline.mjs +9 -1
  41. package/src/core/public-surface.mjs +1 -1
  42. package/src/core/redact.mjs +12 -5
  43. package/src/core/source-authority.mjs +28 -3
  44. package/src/npm/npm-entry-closure.mjs +71 -9
  45. package/src/producers/build-adapters.mjs +2 -1
  46. package/src/producers/render-public-assets.mjs +2 -1
  47. package/src/producers/sync-skills.mjs +2 -1
  48. package/src/snapshot/frozen.mjs +19 -14
  49. package/src/core/run-retention.mjs +0 -227
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -2,11 +2,11 @@
2
2
  "schemaVersion": "1.0.0",
3
3
  "platformId": "release-skill",
4
4
  "platformName": "release-skill Plugin Platform",
5
- "version": "0.9.23",
5
+ "version": "0.9.25",
6
6
  "logicalRoot": "skills-src/",
7
- "logicalRootDigest": "693b190f880abe5a441a5d18bc14b01142806de1289d927cda0da38ad3dd2425",
8
- "projectionDigest": "8c04c93ffc8e5652038dc10020ea00052b6a1dcf1ea70158e35117d6370a6db9",
9
- "projectionId": "release-skill:platform:0.9.23",
7
+ "logicalRootDigest": "7b62f9ded9aea774298ad86a781d58ad83f4092f3c3220b4a75dfe11ad19f809",
8
+ "projectionDigest": "bb92d4a3f57842000aa404a0698c5e38c111f05408027491e0904912af81d716",
9
+ "projectionId": "release-skill:platform:0.9.25",
10
10
  "manifestRequired": true,
11
11
  "projectionSources": {
12
12
  "skills-src-root": {
@@ -207,8 +207,8 @@ v2 的顶层结构封闭(`additionalProperties: false`),只接受 Schema
207
207
 
208
208
  ### 5.1 文件保留
209
209
 
210
- - 不得手工删除运行证据。下一次生产 prepare 会在现有项目锁内执行一次尽力清理,只删除已被更新完整谱系取代的终态谱系,以及没有有效计划或外部写入迹象的封口失败 prepare。
211
- - 当前 prepare、当前计划对应的全部 run、最近完整谱系、任何非终态或部分成功谱系,以及损坏、越界、身份含糊或信息冲突的目录都受保护。清理以 `.release-skill/runs/` 下的真实直接子目录为单位,不删除计划、批准或 ship 记录;单个删除失败只记录目录基名和错误码,不阻断 prepare。
210
+ - 不得手工删除运行证据。生产 prepare 保留 `.release-skill/runs/` 中已有的运行目录,包括封口失败的 prepare 和已经结束的谱系。
211
+ - 生产 prepare 保留的是这些运行目录,不保证目录内每个历史文件的字节永久不变。文件如何写入仍按本节其余证据合同执行。
212
212
  - `evidence.jsonl` 为追加写入,不得在执行过程中被截断。
213
213
  - `summary.json` 在执行结束时原子写入(writer 单次封口:`finish()` 只能成功一次,封口后 append/finish 均为 no-op;正常命令的成功与失败路径都必须封口)。
214
214
  - `states/` 台账为不可变写集:外部检查点成功后立即记账,失败时保留 `PARTIAL` 状态,reconcile 据此跳过已成功步骤。
@@ -22,6 +22,7 @@
22
22
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
25
26
  import { createHash } from 'node:crypto';
26
27
  import { computeBundleSourceDigest } from '../src/core/bundle-freshness.mjs';
27
28
  import {
@@ -32,7 +33,7 @@ import {
32
33
 
33
34
  const CHECK_MODE = process.argv.includes('--check');
34
35
 
35
- const SCRIPT_DIR = dirname(new URL(import.meta.url).pathname);
36
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
36
37
  const PKG_ROOT = join(SCRIPT_DIR, '..');
37
38
  const ENTRY = join(PKG_ROOT, 'bin', 'release-skill-cli.mjs');
38
39
  const OUTFILE = join(PKG_ROOT, 'bin', 'release-skill.bundle.mjs');
@@ -36,7 +36,7 @@ import { PLATFORMS } from '../src/platforms/registry.mjs';
36
36
  // Constants
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- const SCRIPT_DIR = new URL('.', import.meta.url).pathname;
39
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
40
40
  const PKG_ROOT = process.env.RELEASE_SKILL_PKG_ROOT ?? join(SCRIPT_DIR, '..');
41
41
  const PROJECT_YAML = join(PKG_ROOT, '..', '..', '.release-skill', 'project.yaml');
42
42
  const SOURCE_PREFIX = 'packages/release-skill/';
@@ -29,9 +29,9 @@ description: "Discoverable entry point for release-skill: dependency and environ
29
29
 
30
30
  治理诊断只运行 release-skill 自己的只读检查程序,不运行目标 Skill、业务脚本、构建、hook 或发布动作,也不把 `prepare`、`verify`、`release-finish` 当作静态治理入口。用户要求实际接入或发布时,把已有授权带到对应原业务入口;各入口继续执行原有确认、副作用和状态机合同。
31
31
 
32
- ## 0.9.23 候选边界
32
+ ## 0.9.25 候选边界
33
33
 
34
- 当前 0.9.23 候选精确消费 Foundation 0.22.0 的公开包根 API。0.9.23 仍是源码候选,不能从本说明推断已批准、发布或验证。
34
+ 当前 0.9.25 候选精确消费 Foundation 0.22.0 的公开包根 API。0.9.25 仍是源码候选,不能从本说明推断已批准、发布或验证。
35
35
 
36
36
  冻结前,`prepare` 或新建 `ship` 状态可重复传入 `--unit <id>`;不传则选择全部单元。延期单元不进入计划,也不获得发布状态。完整配置、生成物新鲜度和顶层 Hook 仍覆盖全项目;`publicSourceAuthorityReceipt` 的 coordinator 与 subjects 必须共同选择。冻结后以 `plan.units` 为唯一范围,publish、reconcile、verify、distribute 不再接受 `--unit`。
37
37
 
@@ -29,9 +29,9 @@ description: "Discoverable entry point for release-skill: dependency and environ
29
29
 
30
30
  治理诊断只运行 release-skill 自己的只读检查程序,不运行目标 Skill、业务脚本、构建、hook 或发布动作,也不把 `prepare`、`verify`、`release-finish` 当作静态治理入口。用户要求实际接入或发布时,把已有授权带到对应原业务入口;各入口继续执行原有确认、副作用和状态机合同。
31
31
 
32
- ## 0.9.23 候选边界
32
+ ## 0.9.25 候选边界
33
33
 
34
- 当前 0.9.23 候选精确消费 Foundation 0.22.0 的公开包根 API。0.9.23 仍是源码候选,不能从本说明推断已批准、发布或验证。
34
+ 当前 0.9.25 候选精确消费 Foundation 0.22.0 的公开包根 API。0.9.25 仍是源码候选,不能从本说明推断已批准、发布或验证。
35
35
 
36
36
  冻结前,`prepare` 或新建 `ship` 状态可重复传入 `--unit <id>`;不传则选择全部单元。延期单元不进入计划,也不获得发布状态。完整配置、生成物新鲜度和顶层 Hook 仍覆盖全项目;`publicSourceAuthorityReceipt` 的 coordinator 与 subjects 必须共同选择。冻结后以 `plan.units` 为唯一范围,publish、reconcile、verify、distribute 不再接受 `--unit`。
37
37
 
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
  import { mkdtemp } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
19
 
19
20
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
20
21
  import { digestBytes } from 'skill-family-harness-node';
@@ -44,7 +45,7 @@ export { buildProducerGraph };
44
45
  * @returns {string[]} Absolute paths of all reachable local modules.
45
46
  */
46
47
  function collectStaticImports(filePath, visited = new Set()) {
47
- const abs = new URL(`file://${filePath}`).pathname;
48
+ const abs = fileURLToPath(pathToFileURL(filePath));
48
49
  if (visited.has(abs)) return [];
49
50
  visited.add(abs);
50
51
 
@@ -109,7 +110,7 @@ export async function createBuiltInProducerRegistry() {
109
110
  const producers = new Map();
110
111
 
111
112
  // Resolve lockfile path (one level up from package root)
112
- const lockfilePath = join(new URL('../../..', import.meta.url).pathname, 'pnpm-lock.yaml');
113
+ const lockfilePath = join(fileURLToPath(new URL('../../..', import.meta.url)), 'pnpm-lock.yaml');
113
114
  let lockfileBytes;
114
115
  try {
115
116
  lockfileBytes = await readFile(lockfilePath);
@@ -128,7 +129,7 @@ export async function createBuiltInProducerRegistry() {
128
129
  }
129
130
 
130
131
  // Collect full static dependency closure
131
- const modulePath = new URL(moduleUrl).pathname;
132
+ const modulePath = fileURLToPath(moduleUrl);
132
133
  const allModulePaths = collectStaticImports(modulePath);
133
134
  const allModuleBytes = await Promise.all(
134
135
  allModulePaths.map(async (p) => {
@@ -78,7 +78,6 @@ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
78
78
  import { observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
79
79
  import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
80
80
  import { createProductionPrepareRunDir } from '../core/run.mjs';
81
- import { cleanupRunRetention } from '../core/run-retention.mjs';
82
81
  import {
83
82
  PLATFORMS,
84
83
  normalizeHostId,
@@ -279,7 +278,9 @@ import { boundedOutputTail } from '../core/bounded-output.mjs';
279
278
  * already captures child stdout/stderr on non-zero exit; on failure this
280
279
  * layer writes bounded tails into the hooks evidence event AND echoes them to
281
280
  * the current process' stderr, so a failing hook is diagnosable on the
282
- * terminal without opening evidence.jsonl. Success events carry no tails.
281
+ * terminal without opening evidence.jsonl. Fresh success and non-zero exit
282
+ * events also retain the returned full streams through the evidence writer's
283
+ * redaction. Success events carry no tails; cache hits have no fresh streams.
283
284
  * Exit-code semantics are unchanged.
284
285
  *
285
286
  * @param {object} config - The loaded project config.
@@ -413,6 +414,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
413
414
  // streams instead of the noisy compiler prelude at the beginning.
414
415
  stdoutTail,
415
416
  stderrTail,
417
+ details: { stdout: result.stdout, stderr: result.stderr },
416
418
  ...selectionField,
417
419
  });
418
420
  throw new ReleaseError(
@@ -448,6 +450,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
448
450
  status: 'completed',
449
451
  hookName: name,
450
452
  exitCode: 0,
453
+ details: { stdout: result.stdout, stderr: result.stderr },
451
454
  ...selectionField,
452
455
  });
453
456
  records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
@@ -3404,34 +3407,6 @@ export async function prepareRelease(options) {
3404
3407
  const evidence = createEvidenceWriter({ runDir, command: 'prepare', clock });
3405
3408
 
3406
3409
  try {
3407
- // R14-01: production prepare performs one best-effort retention pass only
3408
- // after the current run/evidence authority exists and before high-cost
3409
- // config, snapshot, and hook work begins. It never blocks prepare.
3410
- if (production) {
3411
- try {
3412
- const retention = await (options.runRetentionFn ?? cleanupRunRetention)({
3413
- releaseDir,
3414
- currentRunDir: runDir,
3415
- });
3416
- await evidence.append({
3417
- phase: 'run-retention',
3418
- status: 'completed',
3419
- scanned: retention.scanned,
3420
- deleted: retention.deleted,
3421
- errors: retention.errors,
3422
- });
3423
- } catch (error) {
3424
- await evidence.append({
3425
- phase: 'run-retention',
3426
- status: 'warning',
3427
- scanned: 0,
3428
- deleted: 0,
3429
- errors: 1,
3430
- error: { code: error?.code ?? 'RUN_RETENTION_FAILED', message: error?.message ?? String(error) },
3431
- });
3432
- }
3433
- }
3434
-
3435
3410
  // --- Step 1: Load and validate config ---
3436
3411
  await evidence.append({ phase: 'config', status: 'started' });
3437
3412
 
@@ -292,7 +292,15 @@ async function computeWorkspaceDigest(root) {
292
292
  * @returns {Promise<{ gitHead: string, gitTreeHash: string, workspaceDigest: string, statusEntries: string[], capturedAt: string }>}
293
293
  */
294
294
  export async function captureBaseline(root) {
295
- const opts = { cwd: root, shell: false, encoding: 'utf8' };
295
+ // Same 64 MiB bound as computeWorkspaceDigest: `git status --porcelain=v1
296
+ // -z --untracked-files=all` lists every untracked file and exceeds Node's
297
+ // default 1 MiB execFile maxBuffer once that listing grows past ~1 MiB.
298
+ const opts = {
299
+ cwd: root,
300
+ shell: false,
301
+ encoding: 'utf8',
302
+ maxBuffer: 64 * 1024 * 1024,
303
+ };
296
304
 
297
305
  const [{ stdout: headOut }, { stdout: treeOut }, { stdout: statusOut }] =
298
306
  await Promise.all([
@@ -437,7 +437,7 @@ export async function inspectExpectedPublicSurface({ root, unit } = {}) {
437
437
  let skippedNonRegularCount = 0;
438
438
  for (const rule of resolvedRoots) {
439
439
  const observed = await scanRoot({ workspaceRoot, unit, rule });
440
- observedFiles.push(...observed.files);
440
+ for (const file of observed.files) observedFiles.push(file);
441
441
  skippedNonRegularCount += observed.skippedNonRegularCount;
442
442
  }
443
443
 
@@ -54,12 +54,19 @@ export const REDACTED_PATH_PLACEHOLDER = '<redacted-path>';
54
54
  // (b) UNC paths (\\server\share...);
55
55
  // (c) POSIX absolute paths: a '/' anchored at the start of the string or
56
56
  // preceded by a delimiter (whitespace, quote, '=', ':', ',', '(', '[',
57
- // '{', '<'), running until whitespace or a quote. The left boundary keeps
58
- // redaction from firing inside relative fragments ('src/core/x.mjs'),
59
- // fragment-anchored JSON pointers ('#/required'), or protocol-relative
60
- // URLs ('https://x/y').
57
+ // '{', '<'). Non-final '/'-separated segments may contain spaces (a
58
+ // volume name such as /Volumes/Samsung 990 pro 4T/src/file), so the
59
+ // token continues across those spaces and stops before trailing prose
60
+ // ('plan.json rejected: ...'). The left boundary keeps redaction from
61
+ // firing inside relative fragments ('src/core/x.mjs'), fragment-anchored
62
+ // JSON pointers ('#/required'), or protocol-relative URLs ('https://x/y').
63
+ // Windows drive and UNC alternatives still stop at whitespace; only the
64
+ // POSIX alternative crosses spaces, and only in non-final segments.
65
+ // A segment starting with a space right after '/' (e.g. /Volumes/ 990/x)
66
+ // and Windows drive/UNC tokens still truncate at that whitespace (known
67
+ // limitation).
61
68
  const PATH_TOKEN_RE =
62
- /(?<=^|[\s'"=:,([{<])(?:[A-Za-z]:[\\/][^\s'"]*|\\\\[^\s'"]+|\/[^\s'"]+)/g;
69
+ /(?<=^|[\s'"=:,([{<])(?:[A-Za-z]:[\\/][^\s'"]*|\\\\[^\s'"]+|\/(?:[^/\s'"]+(?:[ ]+[^/\s'"]+)*\/)*[^/\s'"]*)/g;
63
70
 
64
71
  // Trailing prose punctuation that may cling to a token and must survive.
65
72
  const TRAILING_PUNCT_RE = /^(.*?)([.!,;:)\]}>]*)$/;
@@ -272,6 +272,21 @@ function normalizeLocalGitMode(mode) {
272
272
  return (mode & 0o111) === 0 ? '100644' : '100755';
273
273
  }
274
274
 
275
+ function gitPathSegments(value) {
276
+ return String(value).replaceAll('\\', '/').replace(/\/+$/u, '').split('/').filter(Boolean);
277
+ }
278
+
279
+ function sharesDirectoryBoundary(recordPath, requestedPath) {
280
+ const recordSegments = gitPathSegments(recordPath);
281
+ const requestedSegments = gitPathSegments(requestedPath);
282
+ const limit = Math.min(recordSegments.length, requestedSegments.length);
283
+ if (limit === 0) return false;
284
+ for (let index = 0; index < limit; index += 1) {
285
+ if (recordSegments[index] !== requestedSegments[index]) return false;
286
+ }
287
+ return true;
288
+ }
289
+
275
290
  /**
276
291
  * Check only frozen source-input paths for staged, unstaged or untracked
277
292
  * changes. Unrelated dirty files remain allowed.
@@ -310,11 +325,21 @@ export async function checkSourceInputDirty({ closure, root, execFn = execFile }
310
325
  if (record.length < 4) continue;
311
326
  const status = record.slice(0, 2);
312
327
  const recordPath = record.slice(3);
328
+ let renamedPath = '';
329
+ if (status.startsWith('R') || status.startsWith('C')) {
330
+ renamedPath = records[index + 1] ?? '';
331
+ index += 1;
332
+ }
313
333
  // Ignored inputs are still untracked inputs. A file explicitly selected
314
334
  // for publication must not escape the prepare gate merely because a
315
- // .gitignore rule hides it from ordinary status output.
316
- dirtyPaths.push(recordPath);
317
- if (status.startsWith('R') || status.startsWith('C')) index += 1;
335
+ // .gitignore rule hides it from ordinary status output. A porcelain row
336
+ // counts only when its path shares a full directory segment with a
337
+ // requested closure path. A character prefix is not a selection.
338
+ for (const candidate of [recordPath, renamedPath]) {
339
+ if (candidate && paths.some((requested) => sharesDirectoryBoundary(candidate, requested))) {
340
+ dirtyPaths.push(candidate);
341
+ }
342
+ }
318
343
  }
319
344
  return {
320
345
  dirty: dirtyPaths.length > 0,
@@ -84,6 +84,37 @@ function checkTarget({ field, target, fileIndex, requireDotSlash = false }) {
84
84
  };
85
85
  }
86
86
 
87
+ // Identity mapping whose only star is the final character, for example
88
+ // "./schemas/*" -> "./schemas/*". Node replaces that star with any non-empty
89
+ // string, including "/". Other shapes stay rejected.
90
+ function isDirectTrailingStarMapping(key, value) {
91
+ return typeof value === 'string'
92
+ && key === value
93
+ && key.startsWith('./')
94
+ && key.indexOf('*') === key.length - 1;
95
+ }
96
+
97
+ function validateTrailingStarPrefix(pattern) {
98
+ const head = pattern.slice(0, -1);
99
+ if (head === './') return {};
100
+ const staticTarget = head.endsWith('/') ? head.slice(0, -1) : head;
101
+ const validated = validateRelativeTarget(staticTarget, 'exports', { requireDotSlash: true });
102
+ if (validated.error) return validated;
103
+ return {};
104
+ }
105
+
106
+ function matchingIndexPaths(pattern, fileIndex) {
107
+ const prefix = pattern.slice(0, -1);
108
+ const matched = [];
109
+ for (const path of fileIndex.keys()) {
110
+ if (typeof path !== 'string') continue;
111
+ const subpath = `./${path}`;
112
+ if (subpath.startsWith(prefix) && subpath.length >= pattern.length) matched.push(path);
113
+ }
114
+ matched.sort();
115
+ return matched;
116
+ }
117
+
87
118
  function collectExports(value, location, result) {
88
119
  if (value === null) return;
89
120
  if (typeof value === 'string') {
@@ -117,14 +148,18 @@ function collectExports(value, location, result) {
117
148
  for (const [key, child] of Object.entries(value)) {
118
149
  const childLocation = `${location}.${key}`;
119
150
  if (key.includes('*')) {
120
- result.errors.push(
121
- entryError(
122
- 'exports',
123
- key,
124
- 'unsupported_entry_shape',
125
- `${childLocation} uses a wildcard subpath that the static gate cannot verify`,
126
- ),
127
- );
151
+ if (isDirectTrailingStarMapping(key, child)) {
152
+ result.patterns.push({ location: childLocation, pattern: key });
153
+ } else {
154
+ result.errors.push(
155
+ entryError(
156
+ 'exports',
157
+ key,
158
+ 'unsupported_entry_shape',
159
+ `${childLocation} uses a wildcard subpath that the static gate cannot verify`,
160
+ ),
161
+ );
162
+ }
128
163
  continue;
129
164
  }
130
165
  collectExports(child, childLocation, result);
@@ -166,7 +201,7 @@ export function checkNpmEntryClosure(manifest, fileIndex) {
166
201
  }
167
202
 
168
203
  if (manifest.exports !== undefined) {
169
- const exportsResult = { targets: [], errors: [], diagnostics: [] };
204
+ const exportsResult = { targets: [], patterns: [], errors: [], diagnostics: [] };
170
205
  collectExports(manifest.exports, 'exports', exportsResult);
171
206
  result.errors.push(...exportsResult.errors);
172
207
  result.diagnostics.push(...exportsResult.diagnostics);
@@ -180,6 +215,33 @@ export function checkNpmEntryClosure(manifest, fileIndex) {
180
215
  result.entries.push(...checked.entries);
181
216
  result.errors.push(...checked.errors);
182
217
  }
218
+ for (const pattern of exportsResult.patterns) {
219
+ const validated = validateTrailingStarPrefix(pattern.pattern);
220
+ if (validated.error) {
221
+ result.errors.push(validated.error);
222
+ continue;
223
+ }
224
+ const matched = matchingIndexPaths(pattern.pattern, fileIndex);
225
+ if (matched.length === 0) {
226
+ result.errors.push(entryError(
227
+ 'exports',
228
+ pattern.pattern,
229
+ 'empty_pattern_match',
230
+ `${pattern.location} matched no files in the package file index`,
231
+ ));
232
+ continue;
233
+ }
234
+ for (const path of matched) {
235
+ const checked = checkTarget({
236
+ field: 'exports',
237
+ target: `./${path}`,
238
+ fileIndex,
239
+ requireDotSlash: true,
240
+ });
241
+ result.entries.push(...checked.entries);
242
+ result.errors.push(...checked.errors);
243
+ }
244
+ }
183
245
  }
184
246
 
185
247
  return result;
@@ -22,6 +22,7 @@
22
22
  import { readdir, mkdir, lstat, realpath, open, rm } from 'node:fs/promises';
23
23
  import { constants as fsConstants } from 'node:fs';
24
24
  import { join, dirname, relative, isAbsolute, sep, resolve, posix as pathPosix } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
25
26
 
26
27
  import { sha256Hex } from '../core/digest.mjs';
27
28
  import { digestDocument } from 'skill-family-contracts';
@@ -713,7 +714,7 @@ async function generateAdapterFiles(platform, skills, templateJson, root, market
713
714
  * @param {string} [options.platformFilter] - Only generate for this platform name.
714
715
  */
715
716
  export async function produceBuildAdapters({ inputs, output, outputRoots, platformFilter } = {}) {
716
- const defaultRoot = new URL('../..', import.meta.url).pathname;
717
+ const defaultRoot = fileURLToPath(new URL('../..', import.meta.url));
717
718
  const requestedRoot = inputs ?? defaultRoot;
718
719
 
719
720
  // ── Phase 1: Validate input root is not a symlink ──
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
15
15
  import { join, relative } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
16
17
 
17
18
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
18
19
  import { digestDocument } from 'skill-family-contracts';
@@ -111,7 +112,7 @@ async function renderDir(srcDir, outDir, pathPrefix) {
111
112
  * @returns {Promise<{outputs: Array, outputManifestDigest: string}>}
112
113
  */
113
114
  export async function produceRenderPublicAssets({ inputs, output } = {}) {
114
- const defaultRoot = new URL('../../..', import.meta.url).pathname;
115
+ const defaultRoot = fileURLToPath(new URL('../../..', import.meta.url));
115
116
  const root = inputs ?? defaultRoot;
116
117
 
117
118
  const srcDirs = [
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
14
15
 
15
16
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
16
17
  import { digestDocument } from 'skill-family-contracts';
@@ -44,7 +45,7 @@ export function computeSyncSkillsDigest(sourceBytes) {
44
45
  * @param {string} options.output - Output directory path.
45
46
  */
46
47
  export async function produceSyncSkills({ inputs, output } = {}) {
47
- const defaultSrc = new URL('../../skills-src', import.meta.url).pathname;
48
+ const defaultSrc = fileURLToPath(new URL('../../skills-src', import.meta.url));
48
49
  const srcDir = inputs ?? defaultSrc;
49
50
 
50
51
  const skillDirs = [];
@@ -199,9 +199,10 @@ export async function verifyFrozenDirectoryStructure(directory, label = 'frozen
199
199
  *
200
200
  * Consumption only: this never rebuilds objects and never reads the wall
201
201
  * clock. When `commitTimestamp` (the plan's `frozenSnapshot.commitTimestamp`)
202
- * is provided, the frozen commit's real `%aI`/`%cI` dates must match it
203
- * exactly, so a commit carrying any other author/committer time can never
204
- * pass pre-publish verification.
202
+ * is provided, the plan canonical form remains `+00:00`. Git may read the
203
+ * same UTC instant back as `Z` via `%aI`/`%cI`. Both sides pass through
204
+ * `normalizeGitTimestamp` before comparison, so a commit carrying any other
205
+ * author/committer instant can never pass pre-publish verification.
205
206
  */
206
207
  export async function verifyFrozenGitRepository({
207
208
  root,
@@ -225,13 +226,15 @@ export async function verifyFrozenGitRepository({
225
226
  ['--git-dir', gitDir, 'show', '-s', '--format=%aI%n%cI', commit],
226
227
  { shell: false },
227
228
  );
228
- const [authorDate, committerDate] = datesOut.trim().split('\n');
229
+ const [rawAuthorDate, rawCommitterDate] = datesOut.trim().split('\n');
230
+ const authorDate = normalizeGitTimestamp(rawAuthorDate, 'frozen commit author date');
231
+ const committerDate = normalizeGitTimestamp(rawCommitterDate, 'frozen commit committer date');
229
232
  if (authorDate !== expectedTimestamp || committerDate !== expectedTimestamp) {
230
233
  throw frozenError('frozen git commit dates do not match the plan freeze timestamp', {
231
234
  commit,
232
235
  expectedTimestamp,
233
- observedAuthorDate: authorDate,
234
- observedCommitterDate: committerDate,
236
+ observedAuthorDate: rawAuthorDate,
237
+ observedCommitterDate: rawCommitterDate,
235
238
  });
236
239
  }
237
240
  }
@@ -314,9 +317,10 @@ const GIT_ISO8601_STRICT_RE =
314
317
  *
315
318
  * The value must be a non-empty, strict ISO 8601 timestamp with an explicit UTC
316
319
  * offset and a parseable calendar date. It is normalized to UTC with second
317
- * precision and a `+00:00` offset, which is exactly the form Git stores and
318
- * re-emits via `%aI`/`%cI`. Missing, empty, malformed, or out-of-range values
319
- * throw a closed `GATE_FAILED` error.
320
+ * precision and a `+00:00` offset. The plan canonical form remains `+00:00`.
321
+ * Git may read the same UTC instant back as `Z` via `%aI`/`%cI`; both sides
322
+ * pass through `normalizeGitTimestamp` before comparison. Missing, empty,
323
+ * malformed, or out-of-range values throw a closed `GATE_FAILED` error.
320
324
  *
321
325
  * Normalization is idempotent: re-normalizing an already-canonical value returns
322
326
  * the identical string, so prepare and the Git builder always agree byte-for-byte.
@@ -399,11 +403,12 @@ function publicGitRemote({ repo, githubHost = 'github.com' }) {
399
403
  * `commitTimestamp` is the plan freeze timestamp sampled exactly once during
400
404
  * production prepare. It is strictly validated BEFORE any filesystem or Git
401
405
  * write (missing, empty, malformed, or impossible values fail closed), then
402
- * written verbatim to both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE, so the
403
- * frozen commit's `%aI` and `%cI` always equal the plan's
404
- * `frozenSnapshot.commitTimestamp` byte-for-byte. Identical inputs therefore
405
- * yield identical tree and commit identifiers, and a different freeze
406
- * timestamp alone yields a different commit with an identical tree.
406
+ * written verbatim to both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE. The plan
407
+ * canonical form remains `+00:00`. Git may read the same UTC instant back as
408
+ * `Z` via `%aI`/`%cI`; both sides pass through `normalizeGitTimestamp` before
409
+ * comparison. Identical inputs therefore yield identical tree and commit
410
+ * identifiers, and a different freeze timestamp alone yields a different
411
+ * commit with an identical tree.
407
412
  */
408
413
  export async function buildFrozenGitRepository({
409
414
  snapshotDir,