release-skill 0.4.2 → 0.5.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 (86) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +26 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +17 -6
  10. package/README.zh-CN.md +16 -5
  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/error-codes.json +154 -0
  14. package/adapters/claude/bin/kernel-protocol.json +60 -0
  15. package/adapters/claude/bin/registry.json +165 -0
  16. package/adapters/claude/bin/release-skill.bundle.mjs +3838 -1445
  17. package/adapters/claude/bin/rules.json +104 -0
  18. package/adapters/claude/skills/release-assess/SKILL.md +2 -0
  19. package/adapters/claude/skills/release-help/SKILL.md +3 -1
  20. package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
  21. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/error-codes.json +154 -0
  24. package/adapters/codex/bin/kernel-protocol.json +60 -0
  25. package/adapters/codex/bin/registry.json +165 -0
  26. package/adapters/codex/bin/release-skill.bundle.mjs +3838 -1445
  27. package/adapters/codex/bin/rules.json +104 -0
  28. package/adapters/codex/skills/release-assess/SKILL.md +2 -0
  29. package/adapters/codex/skills/release-help/SKILL.md +3 -1
  30. package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
  31. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  32. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  33. package/adapters/kimi/bin/error-codes.json +154 -0
  34. package/adapters/kimi/bin/kernel-protocol.json +60 -0
  35. package/adapters/kimi/bin/registry.json +165 -0
  36. package/adapters/kimi/bin/release-skill.bundle.mjs +3838 -1445
  37. package/adapters/kimi/bin/rules.json +104 -0
  38. package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
  39. package/adapters/kimi/skills/release-help/SKILL.md +3 -1
  40. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
  41. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  42. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  43. package/adapters/workbuddy/bin/error-codes.json +154 -0
  44. package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
  45. package/adapters/workbuddy/bin/registry.json +165 -0
  46. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3838 -1445
  47. package/adapters/workbuddy/bin/rules.json +104 -0
  48. package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
  49. package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
  50. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
  51. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  52. package/bin/error-codes.json +154 -0
  53. package/bin/kernel-protocol.json +60 -0
  54. package/bin/registry.json +165 -0
  55. package/bin/release-skill.bundle.mjs +3838 -1445
  56. package/bin/rules.json +104 -0
  57. package/package.json +3 -1
  58. package/scripts/build-bundle.mjs +159 -5
  59. package/scripts/sync-public-files.mjs +5 -0
  60. package/skills/release-assess/SKILL.md +2 -0
  61. package/skills/release-help/SKILL.md +3 -1
  62. package/skills/release-prepare/SKILL.md +2 -2
  63. package/skills/release-verify/SKILL.md +2 -0
  64. package/skills-src/release-assess/SKILL.md +2 -0
  65. package/skills-src/release-help/SKILL.md +3 -1
  66. package/skills-src/release-prepare/SKILL.md +2 -2
  67. package/skills-src/release-verify/SKILL.md +2 -0
  68. package/src/adapters/contract.mjs +10 -10
  69. package/src/artifacts/entry.mjs +4 -1
  70. package/src/artifacts/producer-registry.mjs +9 -6
  71. package/src/artifacts/project-lock.mjs +324 -214
  72. package/src/commands/assess.mjs +60 -2
  73. package/src/commands/attest.mjs +11 -0
  74. package/src/core/approval.mjs +5 -2
  75. package/src/core/digest.mjs +47 -12
  76. package/src/core/foundation-inflight.mjs +39 -0
  77. package/src/core/hook-cache.mjs +3 -0
  78. package/src/core/installation-contract.mjs +4 -2
  79. package/src/core/plan.mjs +30 -27
  80. package/src/core/run.mjs +22 -36
  81. package/src/core/skill-resource-closure.mjs +6 -4
  82. package/src/core/trusted-resource.mjs +64 -15
  83. package/src/producers/build-adapters.mjs +27 -9
  84. package/src/producers/render-public-assets.mjs +12 -7
  85. package/src/producers/sync-skills.mjs +12 -7
  86. package/src/snapshot/public-map.mjs +10 -2
@@ -105,6 +105,50 @@ function unitFile(unit, file) {
105
105
  return unit.source === '.' ? file : `${unit.source}/${file}`;
106
106
  }
107
107
 
108
+ /**
109
+ * Decide whether a failed `npm view` check proves the queried version is
110
+ * absent from the registry.
111
+ *
112
+ * Only an explicit E404/ETARGET from npm (the registry answered "not found",
113
+ * either for the package or for the exact version) is trusted as proof of
114
+ * absence. Network errors, auth failures, timeouts, a missing npm CLI, and
115
+ * any other failure remain unknown: they must be reported as a failed check
116
+ * instead of being silently treated as "no gap".
117
+ *
118
+ * @param {Error & { stdout?: string, stderr?: string, message?: string }} error - execFile rejection.
119
+ * @returns {boolean} true when npm explicitly reported the version as absent.
120
+ */
121
+ function isVersionAbsentError(error) {
122
+ const text = [
123
+ error?.stdout,
124
+ error?.stderr,
125
+ error?.message,
126
+ ]
127
+ .filter((part) => typeof part === 'string')
128
+ .join('\n');
129
+ return /\bE404\b|\bETARGET\b/i.test(text);
130
+ }
131
+
132
+ /**
133
+ * Extract a short, safe error token from a failed `npm view` invocation.
134
+ *
135
+ * Only npm's machine error code (e.g. ECONNREFUSED, E404) or a spawn-level
136
+ * code (e.g. ENOENT for a missing npm CLI) is returned. Raw stderr is never
137
+ * embedded because it can contain absolute paths and npm log file locations.
138
+ *
139
+ * @param {Error & { code?: unknown, stderr?: string, message?: string }} error - execFile rejection.
140
+ * @returns {string} npm error code, spawn-level code, or 'UNKNOWN'.
141
+ */
142
+ function describeNpmError(error) {
143
+ const spawnCode = error?.code;
144
+ if (typeof spawnCode === 'string' && /^[A-Z][A-Z0-9_]{2,}$/.test(spawnCode)) {
145
+ return spawnCode;
146
+ }
147
+ const text = `${error?.stderr ?? ''}\n${error?.message ?? ''}`;
148
+ const match = /\bcode\s+([A-Z][A-Z0-9_]{2,})\b/i.exec(text);
149
+ return match ? match[1] : 'UNKNOWN';
150
+ }
151
+
108
152
  /**
109
153
  * Determine the project topology from the loaded config.
110
154
  *
@@ -643,8 +687,22 @@ async function checkRemotePrerequisites(root, config, offline) {
643
687
  message: `npm 包 ${npmDist.package}@${version} 已存在于 registry`,
644
688
  }),
645
689
  );
646
- } catch {
647
- // Version not published -- good, no gap
690
+ } catch (error) {
691
+ // Only an explicit E404/ETARGET from the registry proves the version is
692
+ // absent -- good, no gap. All other failures (network, auth, timeout,
693
+ // missing npm CLI) are a failed check and must be reported instead of
694
+ // being silently treated as "version not published".
695
+ if (!isVersionAbsentError(error)) {
696
+ gaps.push(
697
+ createGap({
698
+ scope: GapScope.PROFILE,
699
+ category: GapCategory.REMOTE,
700
+ severity: Severity.WARNING,
701
+ code: 'NPM_VERSION_CHECK_FAILED',
702
+ message: `npm 包 ${npmDist.package}@${version} 的 registry 版本检查失败(${describeNpmError(error)}),无法确认该版本是否已发布,已跳过版本冲突检查`,
703
+ }),
704
+ );
705
+ }
648
706
  }
649
707
  }
650
708
 
@@ -85,6 +85,17 @@ async function validateInstalledConsumerClosure({
85
85
  * Record the human fact needed by an interactive-only consumer. Identity
86
86
  * fields come exclusively from the generated requirement; the operator only
87
87
  * supplies the result, actor and optional observed install facts.
88
+ *
89
+ * Proof-boundary note: the receipt is a local, self-declared record, not a
90
+ * signed attestation. `--actor` is validated only as a non-empty string and
91
+ * there is no external signature or identity verification, so any process
92
+ * that can run the CLI can claim any actor name. Forging a receipt already
93
+ * requires write access to the `.release-skill` authority directory, which
94
+ * is the same trust boundary as the receipt files themselves. New plans do
95
+ * not use this path: Kimi/CodeBuddy installations are collected as
96
+ * `manualFollowUps` with `verifiedBySystem: false` and never participate in
97
+ * the `VERIFIED` terminal state. This legacy command only supports old
98
+ * frozen plans that predate the manualFollowUps strategy.
88
99
  */
89
100
  export async function recordManualAttestation(options = {}, injected = {}) {
90
101
  const {
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
26
26
  import { computePlanDigest } from './plan.mjs';
27
- import { sha256Hex } from './digest.mjs';
27
+ import { digestBytes } from 'skill-family-harness-node';
28
28
  import { readFile } from 'node:fs/promises';
29
29
  import { basename, dirname, join, resolve } from 'node:path';
30
30
  import Ajv from 'ajv';
@@ -50,7 +50,10 @@ export function validateApprovalRecordSchema(approval) {
50
50
  }
51
51
 
52
52
  export function computeApprovalDigest(rawApproval) {
53
- return sha256Hex(typeof rawApproval === 'string' || Buffer.isBuffer(rawApproval)
53
+ // 摘要机制委托 Foundation digestBytes。注意:rawApproval 对象保持本地
54
+ // 插入序 pretty-print JSON.stringify(approval 记录格式语义),不用
55
+ // canonicalJson —— 迁移前后字节必须逐位一致(冻结证据见 1.6/pre-freeze)。
56
+ return digestBytes(typeof rawApproval === 'string' || Buffer.isBuffer(rawApproval)
54
57
  ? rawApproval
55
58
  : JSON.stringify(rawApproval, null, 2));
56
59
  }
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Deterministic canonical JSON serialisation and SHA-256 digest.
3
3
  *
4
+ * 机制已委托 Foundation(skill-family-foundation-adoption-20260815 节点 1.6):
5
+ * - `canonicalJson` 的权威序列化由 `skill-family-contracts` 的 canonicalJson
6
+ * 承担(键递归排序 + JSON.stringify,严格 JSON 数据域);本地保留宽松输入域
7
+ * 归一化包装(undefined 对象属性省略 / 数组 undefined→null / Date→ISO 字符串 /
8
+ * Buffer→{data,type} / NaN→null),与迁移前的本地宽松字节语义完全一致。
9
+ * - `sha256Hex` 委托 `skill-family-harness-node` 的 digestBytes(同一
10
+ * createHash('sha256') 实现,字节一致)。
11
+ *
4
12
  * `canonicalJson` recursively sorts object keys (deep-first) while preserving
5
13
  * array element order, then serialises the result as a UTF-8 JSON string.
6
14
  * Two objects with the same logical content but different key insertion order
@@ -12,13 +20,14 @@
12
20
  * @module digest
13
21
  */
14
22
 
15
- import { createHash } from 'node:crypto';
23
+ import { canonicalJson as contractsCanonicalJson } from 'skill-family-contracts';
24
+ import { digestBytes } from 'skill-family-harness-node';
16
25
 
17
26
  /**
18
27
  * Recursively sort every object key in depth-first order and serialise as
19
28
  * a deterministic UTF-8 JSON string.
20
29
  *
21
- * Rules:
30
+ * Rules (本地宽松语义,迁移前后字节一致):
22
31
  * - Object keys are sorted lexicographically (same order as `Array.sort()`).
23
32
  * - Array element order is preserved.
24
33
  * - Primitives (`null`, booleans, numbers, strings) pass through unchanged.
@@ -27,39 +36,59 @@ import { createHash } from 'node:crypto';
27
36
  * - `BigInt` values throw (matching `JSON.stringify`).
28
37
  * - `Date` objects are serialised via `.toISOString()` (matching
29
38
  * `JSON.stringify`).
39
+ * - `NaN`/`Infinity` become `null`; function/symbol object properties are
40
+ * omitted, array elements become `null` (matching `JSON.stringify`).
41
+ *
42
+ * 输入域适配后再委托 Foundation contracts 的严格权威序列化,因此对纯 JSON
43
+ * 输入与 Foundation `digestDocument` 完全同构。
30
44
  *
31
45
  * @param {*} obj - Any JSON-serialisable value.
32
46
  * @returns {string} A UTF-8 JSON string whose key ordering is deterministic.
33
47
  */
34
48
  export function canonicalJson(obj) {
35
- return JSON.stringify(canonicalise(obj));
49
+ const normalized = normalizeLenient(obj);
50
+ // JSON.stringify 顶层对 undefined/function/symbol 返回 undefined(本地原语义)。
51
+ if (normalized === undefined || typeof normalized === 'function' || typeof normalized === 'symbol') {
52
+ return undefined;
53
+ }
54
+ return contractsCanonicalJson(normalized);
36
55
  }
37
56
 
38
57
  /**
39
58
  * Compute the SHA-256 digest of a UTF-8 string or Buffer.
40
59
  *
60
+ * 委托 Foundation harness-node `digestBytes`(实现与本地 createHash 完全一致)。
61
+ *
41
62
  * @param {string | Buffer} input - The data to hash.
42
63
  * @returns {string} Lowercase hexadecimal SHA-256 digest (64 hex chars).
43
64
  */
44
65
  export function sha256Hex(input) {
45
- return createHash('sha256').update(input).digest('hex');
66
+ return digestBytes(input);
46
67
  }
47
68
 
48
69
  // ---- internal helpers (not exported) ----
49
70
 
50
71
  /**
51
- * Deep-clone a value while sorting all object keys lexicographically.
72
+ * 宽松输入域归一化:把本地历史接受的、非严格 JSON 的值转换为与
73
+ * `JSON.stringify` 一致的严格 JSON 兼容值,再交由 Foundation 权威序列化。
74
+ * 该函数产生的字节序列与迁移前的本地 canonicalise + JSON.stringify 完全一致。
52
75
  *
53
76
  * @param {*} value
54
77
  * @returns {*}
55
78
  */
56
- function canonicalise(value) {
79
+ function normalizeLenient(value) {
57
80
  if (value === null || value === undefined) {
58
81
  return value;
59
82
  }
60
83
 
61
84
  if (Array.isArray(value)) {
62
- return value.map((item) => canonicalise(item));
85
+ return value.map((item) => {
86
+ const normalized = normalizeLenient(item);
87
+ // JSON.stringify 把数组中的 undefined/function/symbol 序列化为 null。
88
+ return (normalized === undefined || typeof normalized === 'function' || typeof normalized === 'symbol')
89
+ ? null
90
+ : normalized;
91
+ });
63
92
  }
64
93
 
65
94
  // Date gets its own branch so we can call toISOString() before the
@@ -71,20 +100,26 @@ function canonicalise(value) {
71
100
  // Buffer gets its own branch: toJSON() returns {type:'Buffer', data:[...]}
72
101
  // which matches JSON.stringify and survives a JSON roundtrip.
73
102
  if (Buffer.isBuffer(value)) {
74
- return canonicalise(value.toJSON());
103
+ return normalizeLenient(value.toJSON());
75
104
  }
76
105
 
77
106
  if (typeof value === 'object') {
78
107
  const sorted = {};
79
108
  for (const key of Object.keys(value).sort()) {
80
109
  const v = value[key];
81
- // Skip undefined object properties (mirrors JSON.stringify behaviour).
82
- if (v === undefined) continue;
83
- sorted[key] = canonicalise(v);
110
+ // JSON.stringify 语义:对象中 undefined/function/symbol 属性被省略。
111
+ if (v === undefined || typeof v === 'function' || typeof v === 'symbol') continue;
112
+ sorted[key] = normalizeLenient(v);
84
113
  }
85
114
  return sorted;
86
115
  }
87
116
 
88
- // Primitives: string, number, boolean, null.
117
+ // JSON.stringify NaN/±Infinity 序列化为 null
118
+ if (typeof value === 'number' && !Number.isFinite(value)) {
119
+ return null;
120
+ }
121
+
122
+ // Primitives: string, number, boolean, null, bigint (bigint 由 Foundation
123
+ // 严格序列化抛 TypeError,与本地 JSON.stringify 抛 TypeError 一致)。
89
124
  return value;
90
125
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Foundation capability bridge (G4 conditional adoption, released form).
3
+ *
4
+ * 背景:1.5 revision 2 把 writePlanImmutable / writeRunAtomic / acquireProjectLock /
5
+ * breakProjectLock 四条件项委托给 Foundation 能力(strict-file-publication 的
6
+ * publishFileExclusive、token-lock 的 acquireFilesystemLock / releaseFilesystemLock /
7
+ * recoverFilesystemLock / inspectFilesystemLock),其不在已发布
8
+ * skill-family-harness-node@0.3.0 导出面,只存在于 Foundation 工作树在途改动。
9
+ *
10
+ * 解锁路径(用户裁决):Foundation 0.4.0 于 2026-08-16 发布(npm 三包
11
+ * contracts / harness-node / engineering-kit,latest=0.4.0),harness-node 0.4.0
12
+ * index.mjs 导出 publishFileExclusive 与 token-lock 五函数及 HARNESS_ERROR_KINDS。
13
+ * 本桥从 5 级相对路径 import(包外工作树引用)切换为包名 import
14
+ * ('skill-family-harness-node'),随 npm 依赖发布,不再依赖 Foundation 工作树在旁。
15
+ *
16
+ * 发布形态:包名 import 可直接用于包内 src(node_modules 解析),bundle
17
+ * (bin/release-skill.bundle.mjs,esbuild 内联)保持自包含。
18
+ *
19
+ * vendor/foundation-pin 与 scripts/materialize-foundation-pin.mjs 因语义失效
20
+ * (pin 指向工作树在途字节)按 D4 裁决退役并归档,不再存在检查期漂移证据。
21
+ */
22
+
23
+ import {
24
+ publishFileExclusive,
25
+ acquireFilesystemLock,
26
+ inspectFilesystemLock,
27
+ releaseFilesystemLock,
28
+ recoverFilesystemLock,
29
+ HARNESS_ERROR_KINDS,
30
+ } from 'skill-family-harness-node';
31
+
32
+ export {
33
+ publishFileExclusive,
34
+ acquireFilesystemLock,
35
+ inspectFilesystemLock,
36
+ releaseFilesystemLock,
37
+ recoverFilesystemLock,
38
+ HARNESS_ERROR_KINDS,
39
+ };
@@ -166,6 +166,9 @@ export async function computeHookCacheKey(hook, root) {
166
166
  fileEntries.push({ path: relPath, sha256: sha256Hex(content) });
167
167
  }
168
168
 
169
+ // 摘要机制已委托 Foundation:canonicalJson(contracts 权威序列化 + 本地宽松
170
+ // 输入域包装)与 sha256Hex(harness-node digestBytes)。拼接哈希组合无法用
171
+ // digestDocument 表达,保持组合结构不变(迁移前后字节一致)。
169
172
  const cacheKey = sha256Hex(canonicalJson(hook) + canonicalJson(fileEntries));
170
173
  return { cacheKey, matchedFiles: matched };
171
174
  }
@@ -24,7 +24,7 @@
24
24
  * @module core/installation-contract
25
25
  */
26
26
 
27
- import { canonicalJson, sha256Hex } from './digest.mjs';
27
+ import { digestDocument } from 'skill-family-contracts';
28
28
 
29
29
  /**
30
30
  * 安装契约摘要算法版本。
@@ -271,8 +271,10 @@ export function buildInstallationContract({
271
271
  * @returns {string} SHA-256 摘要(64 位十六进制)
272
272
  */
273
273
  export function computeInstallationContractDigest(params) {
274
+ // 契约对象为纯 JSON(深度冻结、无 undefined/Date/NaN),直接委托 Foundation
275
+ // digestDocument(= sha256(contracts canonicalJson),与迁移前字节一致)。
274
276
  const contract = buildInstallationContract(params);
275
- return sha256Hex(canonicalJson(contract));
277
+ return digestDocument(contract);
276
278
  }
277
279
 
278
280
  /**
package/src/core/plan.mjs CHANGED
@@ -15,10 +15,13 @@
15
15
  * @module core/plan
16
16
  */
17
17
 
18
- import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
18
+ import { readFile, mkdir, lstat } from 'node:fs/promises';
19
19
  import { basename, dirname, join, resolve, parse, sep } from 'node:path';
20
20
  import Ajv from 'ajv';
21
21
  import addFormats from 'ajv-formats';
22
+ import { digestDocument } from 'skill-family-contracts';
23
+ import { writeFileAtomic } from 'skill-family-harness-node';
24
+ import { publishFileExclusive, HARNESS_ERROR_KINDS as INFLIGHT_KINDS } from './foundation-inflight.mjs';
22
25
  import { canonicalJson, sha256Hex } from './digest.mjs';
23
26
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
27
  import { readTrustedPackageResource } from './trusted-resource.mjs';
@@ -248,6 +251,11 @@ function stripRecordLayerV2(plan) {
248
251
  * @returns {string} Lowercase 64-char hex SHA-256 digest.
249
252
  */
250
253
  export function computePlanDigest(plan) {
254
+ // 输入域适配:计划绑定层可能含条件置 undefined 的字段(如 codebuddy 的
255
+ // marketplace),本地语义为省略该字段。因此不直接用严格 digestDocument,
256
+ // 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
257
+ // 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
258
+ // 完全一致,对含 undefined 的计划保持迁移前字节语义。
251
259
  if (plan.planVersion === 2) {
252
260
  return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
253
261
  }
@@ -286,15 +294,11 @@ export async function writePlanAtomic(planPath, plan) {
286
294
  // 4. Serialise
287
295
  const json = JSON.stringify(augmented, null, 2);
288
296
 
289
- // 5. Write to temp file in the same directory
297
+ // 5. Write atomically via Foundation (contained, exclusive temp, fsync, rename)
290
298
  const dir = dirname(planPath);
291
299
  await prepareAuthorityDirectory(dir);
292
300
  await assertAuthorityFileTarget(planPath);
293
- const tmpPath = `${dir}/.release-plan-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
294
- await writeFile(tmpPath, json, 'utf8');
295
-
296
- // 6. Atomic rename
297
- await rename(tmpPath, planPath);
301
+ await writeFileAtomic(dir, basename(planPath), json);
298
302
 
299
303
  return { planPath, planDigest };
300
304
  }
@@ -303,9 +307,11 @@ export async function writePlanAtomic(planPath, plan) {
303
307
  * Create a digest-addressed plan authority exactly once.
304
308
  *
305
309
  * The target must be named by the computed digest. An existing byte-identical
306
- * authority is reused; an existing divergent file fails closed. A temporary
307
- * file is fsynced and atomically linked into place so concurrent prepares can
308
- * never replace an authority that another process already created.
310
+ * authority is reused; an existing divergent file fails closed. The write
311
+ * delegates to Foundation `publishFileExclusive` (temp + fsync + exclusive
312
+ * link + byte/mode/identity verification + directory fsync); this wrapper
313
+ * supplies the idempotent-same-bytes branch that the in-flight Foundation
314
+ * version does not have (G4 conflict-surface.md 第一节).
309
315
  */
310
316
  export async function writePlanImmutable(planPath, plan) {
311
317
  const planDigest = computePlanDigest(plan);
@@ -324,21 +330,15 @@ export async function writePlanImmutable(planPath, plan) {
324
330
  const dir = dirname(planPath);
325
331
  await prepareAuthorityDirectory(dir);
326
332
  await assertAuthorityFileTarget(planPath);
327
- const tmpPath = join(dir, `.release-plan-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
328
- const handle = await open(tmpPath, 'wx', 0o600);
329
- try {
330
- await handle.writeFile(json, 'utf8');
331
- await handle.sync();
332
- } finally {
333
- await handle.close();
334
- }
335
333
 
336
334
  try {
337
- await link(tmpPath, planPath);
338
- } catch (error) {
339
- if (error.code !== 'EEXIST') throw error;
340
- const existing = await readFile(planPath, 'utf8');
341
- if (existing !== json) {
335
+ await publishFileExclusive(dir, basename(planPath), json, { mode: 0o600 });
336
+ } catch (cause) {
337
+ if (cause?.details?.kind === INFLIGHT_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
338
+ // 同字节幂等:目标已存在且字节相同 幂等成功(Foundation 在途版
339
+ // EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
340
+ const existing = await readFile(planPath, 'utf8').catch(() => null);
341
+ if (existing === json) return { planPath, planDigest };
342
342
  // planVersion 2 (design: t1-2-digest-decoupling.md §4.1): record-layer
343
343
  // fields (status/createdAt/baseline) are excluded from the digest, so
344
344
  // re-preparing over identical binding content yields the SAME digest
@@ -346,7 +346,7 @@ export async function writePlanImmutable(planPath, plan) {
346
346
  // it is genuinely self-consistent (its embedded digest and recomputed
347
347
  // digest both equal this digest) -- the authority is never replaced.
348
348
  // Any other divergence (tampering, malformed file) still fails closed.
349
- if (plan.planVersion === 2) {
349
+ if (plan.planVersion === 2 && existing !== null) {
350
350
  let existingPlan = null;
351
351
  try {
352
352
  existingPlan = JSON.parse(existing);
@@ -367,8 +367,11 @@ export async function writePlanImmutable(planPath, plan) {
367
367
  { planPath, planDigest },
368
368
  );
369
369
  }
370
- } finally {
371
- await unlink(tmpPath).catch(() => {});
370
+ throw new ReleaseError(
371
+ GATE_FAILED,
372
+ `immutable plan authority write failed: ${cause?.message ?? String(cause)}`,
373
+ { planPath, planDigest },
374
+ );
372
375
  }
373
376
 
374
377
  return { planPath, planDigest };
@@ -1149,7 +1152,7 @@ export function validatePlanActionCompleteness(plan, options = {}) {
1149
1152
  // 不能只比较两个可一起篡改的字符串;必须重新由 distribution 的 installationContract 计算并验证
1150
1153
  if (hasFieldGroup && dist.installationContract) {
1151
1154
  // 从 distribution 的 installationContract 重新计算摘要
1152
- const recomputedDigest = sha256Hex(canonicalJson(dist.installationContract));
1155
+ const recomputedDigest = digestDocument(dist.installationContract);
1153
1156
  const actionDigest = action.parameters?.installationContractDigest;
1154
1157
  const distDigest = dist.installationContractDigest;
1155
1158
 
package/src/core/run.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * @module core/run
13
13
  */
14
14
 
15
- import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises';
15
+ import { lstat, mkdir, readFile } from 'node:fs/promises';
16
16
  import { realpathSync } from 'node:fs';
17
17
  import { dirname, join, resolve, basename, relative, isAbsolute } from 'node:path';
18
18
  import Ajv from 'ajv';
@@ -22,6 +22,7 @@ import { assertImmutablePlanAuthority, computePlanDigest } from './plan.mjs';
22
22
  import { sha256Hex } from './digest.mjs';
23
23
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
24
  import { readTrustedPackageResource } from './trusted-resource.mjs';
25
+ import { publishFileExclusive, HARNESS_ERROR_KINDS as INFLIGHT_KINDS } from './foundation-inflight.mjs';
25
26
 
26
27
  const RELEASE_RUN_SCHEMA = JSON.parse((await readTrustedPackageResource(
27
28
  'schemas/release-run.schema.json',
@@ -578,25 +579,18 @@ function sealRun(run) {
578
579
  return sealed;
579
580
  }
580
581
 
581
- async function syncDirectory(dir) {
582
- const handle = await open(dir, 'r');
583
- try {
584
- await handle.sync();
585
- } finally {
586
- await handle.close();
587
- }
588
- }
589
-
590
582
  /**
591
583
  * Write a run file atomically to disk.
592
584
  *
593
585
  * Validates the run against the schema before writing.
594
- * Uses temp-file + rename for atomicity.
586
+ * Delegates to Foundation `publishFileExclusive` (temp + fsync + exclusive
587
+ * link + byte/mode/identity verification + directory fsync).
595
588
  *
596
589
  * **Exclusive-create semantics**: if a file already exists at `runPath`
597
590
  * with different bytes, the write fails. This prevents a stale run from
598
591
  * silently overwriting a newer run record. Writes are idempotent when
599
- * the content is byte-identical.
592
+ * the content is byte-identical (wrapper-supplied branch; the in-flight
593
+ * Foundation version has no same-bytes idempotency — G4 conflict-surface.md).
600
594
  *
601
595
  * @param {string} runPath - Absolute path to write the run to.
602
596
  * @param {Object} run - The run object to write.
@@ -608,34 +602,26 @@ export async function writeRunAtomic(runPath, run) {
608
602
  const sealed = sealRun(run);
609
603
  const json = JSON.stringify(sealed, null, 2);
610
604
  const dir = dirname(runPath);
611
- const tmpPath = `${dir}/.release-run-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
612
605
  await mkdir(dir, { recursive: true });
613
606
 
614
- const handle = await open(tmpPath, 'wx', 0o600);
615
607
  try {
616
- await handle.writeFile(json, 'utf8');
617
- await handle.sync();
618
- } finally {
619
- await handle.close();
620
- }
621
-
622
- try {
623
- try {
624
- await link(tmpPath, runPath);
625
- await syncDirectory(dir);
626
- } catch (error) {
627
- if (error.code !== 'EEXIST') throw error;
628
- const existing = await readFile(runPath, 'utf8');
629
- if (existing !== json) {
630
- throw new ReleaseError(
631
- GATE_FAILED,
632
- 'run file already exists with different bytes; exclusive-create rejected overwrite',
633
- { runPath },
634
- );
635
- }
608
+ await publishFileExclusive(dir, basename(runPath), json, { mode: 0o600 });
609
+ } catch (cause) {
610
+ if (cause?.details?.kind === INFLIGHT_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
611
+ // 同字节幂等:目标已存在且字节相同 → 幂等成功(fail-closed 无双路径)。
612
+ const existing = await readFile(runPath, 'utf8').catch(() => null);
613
+ if (existing === json) return Object.freeze(sealed);
614
+ throw new ReleaseError(
615
+ GATE_FAILED,
616
+ 'run file already exists with different bytes; exclusive-create rejected overwrite',
617
+ { runPath },
618
+ );
636
619
  }
637
- } finally {
638
- await unlink(tmpPath).catch(() => {});
620
+ throw new ReleaseError(
621
+ GATE_FAILED,
622
+ `run file write failed: ${cause?.message ?? String(cause)}`,
623
+ { runPath },
624
+ );
639
625
  }
640
626
 
641
627
  return Object.freeze(sealed);
@@ -10,7 +10,7 @@
10
10
  import { lstat, readFile, readdir } from 'node:fs/promises';
11
11
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
12
 
13
- import { canonicalJson, sha256Hex } from './digest.mjs';
13
+ import { canonicalJson, digestDocument } from 'skill-family-contracts';
14
14
  import { GATE_FAILED, ReleaseError } from './errors.mjs';
15
15
  import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
16
16
 
@@ -258,6 +258,8 @@ export function createSkillResourceClosureReceipt(result, identity = {}) {
258
258
  }
259
259
 
260
260
  export function assertSkillResourceClosureReceipt(expected, observed, label = 'release unit') {
261
+ // 收据对象均为纯 JSON(JSON.parse 派生或固定字段投影),直接委托 Foundation
262
+ // contracts 严格 canonicalJson 比较(非 JSON 输入 fail-closed,抛 TypeError)。
261
263
  if (canonicalJson(expected) !== canonicalJson(observed)) {
262
264
  throw new ReleaseError(
263
265
  GATE_FAILED,
@@ -319,9 +321,9 @@ export async function checkSkillResourceClosure({
319
321
  })).digest;
320
322
  } catch (error) {
321
323
  snapshotError = error;
322
- inputDigest = sha256Hex(canonicalJson({
324
+ inputDigest = digestDocument({
323
325
  invalidTree: error.message,
324
- }));
326
+ });
325
327
  }
326
328
  const skillPaths = await discoverSkills(scanRoot);
327
329
  const findings = [];
@@ -426,6 +428,6 @@ export async function checkSkillResourceClosure({
426
428
  sourceOnlyCount,
427
429
  findings,
428
430
  };
429
- result.receiptDigest = sha256Hex(canonicalJson(receiptProjection(result)));
431
+ result.receiptDigest = digestDocument(receiptProjection(result));
430
432
  return result;
431
433
  }