release-skill 0.4.1 → 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 (100) 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 +41 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +27 -9
  10. package/README.zh-CN.md +23 -8
  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 +3918 -1462
  17. package/adapters/claude/bin/rules.json +104 -0
  18. package/adapters/claude/schemas/release-plan.schema.json +5 -0
  19. package/adapters/claude/schemas/release-project.schema.json +5 -0
  20. package/adapters/claude/skills/release-assess/SKILL.md +2 -0
  21. package/adapters/claude/skills/release-help/SKILL.md +3 -1
  22. package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
  23. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/error-codes.json +154 -0
  26. package/adapters/codex/bin/kernel-protocol.json +60 -0
  27. package/adapters/codex/bin/registry.json +165 -0
  28. package/adapters/codex/bin/release-skill.bundle.mjs +3918 -1462
  29. package/adapters/codex/bin/rules.json +104 -0
  30. package/adapters/codex/schemas/release-plan.schema.json +5 -0
  31. package/adapters/codex/schemas/release-project.schema.json +5 -0
  32. package/adapters/codex/skills/release-assess/SKILL.md +2 -0
  33. package/adapters/codex/skills/release-help/SKILL.md +3 -1
  34. package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
  35. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/error-codes.json +154 -0
  38. package/adapters/kimi/bin/kernel-protocol.json +60 -0
  39. package/adapters/kimi/bin/registry.json +165 -0
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +3918 -1462
  41. package/adapters/kimi/bin/rules.json +104 -0
  42. package/adapters/kimi/schemas/release-plan.schema.json +5 -0
  43. package/adapters/kimi/schemas/release-project.schema.json +5 -0
  44. package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
  45. package/adapters/kimi/skills/release-help/SKILL.md +3 -1
  46. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
  47. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  48. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  49. package/adapters/workbuddy/bin/error-codes.json +154 -0
  50. package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
  51. package/adapters/workbuddy/bin/registry.json +165 -0
  52. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3918 -1462
  53. package/adapters/workbuddy/bin/rules.json +104 -0
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +5 -0
  55. package/adapters/workbuddy/schemas/release-project.schema.json +5 -0
  56. package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
  57. package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
  58. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
  59. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  60. package/bin/error-codes.json +154 -0
  61. package/bin/kernel-protocol.json +60 -0
  62. package/bin/registry.json +165 -0
  63. package/bin/release-skill.bundle.mjs +3918 -1462
  64. package/bin/rules.json +104 -0
  65. package/package.json +3 -1
  66. package/schemas/release-plan.schema.json +5 -0
  67. package/schemas/release-project.schema.json +5 -0
  68. package/scripts/build-bundle.mjs +159 -5
  69. package/scripts/sync-public-files.mjs +5 -0
  70. package/skills/release-assess/SKILL.md +2 -0
  71. package/skills/release-help/SKILL.md +3 -1
  72. package/skills/release-prepare/SKILL.md +2 -2
  73. package/skills/release-verify/SKILL.md +2 -0
  74. package/skills-src/release-assess/SKILL.md +2 -0
  75. package/skills-src/release-help/SKILL.md +3 -1
  76. package/skills-src/release-prepare/SKILL.md +2 -2
  77. package/skills-src/release-verify/SKILL.md +2 -0
  78. package/src/adapters/contract.mjs +10 -10
  79. package/src/adapters/plugin-marketplace.mjs +21 -5
  80. package/src/artifacts/entry.mjs +4 -1
  81. package/src/artifacts/producer-registry.mjs +9 -6
  82. package/src/artifacts/project-lock.mjs +324 -214
  83. package/src/commands/assess.mjs +60 -2
  84. package/src/commands/attest.mjs +11 -0
  85. package/src/commands/prepare.mjs +19 -13
  86. package/src/core/approval.mjs +5 -2
  87. package/src/core/digest.mjs +47 -12
  88. package/src/core/foundation-inflight.mjs +39 -0
  89. package/src/core/hook-cache.mjs +3 -0
  90. package/src/core/installation-contract.mjs +4 -2
  91. package/src/core/plan.mjs +34 -30
  92. package/src/core/run.mjs +22 -36
  93. package/src/core/skill-resource-closure.mjs +6 -4
  94. package/src/core/trusted-resource.mjs +64 -15
  95. package/src/platforms/codebuddy.mjs +92 -10
  96. package/src/platforms/registry.mjs +4 -3
  97. package/src/producers/build-adapters.mjs +27 -9
  98. package/src/producers/render-public-assets.mjs +12 -7
  99. package/src/producers/sync-skills.mjs +12 -7
  100. 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 {
@@ -1289,19 +1289,17 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1289
1289
  });
1290
1290
  // Consumer marketplace install actions (only when distribution
1291
1291
  // declared), driven by the platform registry (T2.2 step 3): one loop
1292
- // body for every platform; the registry declares the per-platform
1293
- // differences (actionType, distributionType, adapter, and via the
1294
- // schema required fields marketplace identity, which kimi does not
1295
- // carry: Kimi Code has no non-interactive install/marketplace API, so
1296
- // the kimi action carries no marketplace identity (MINOR-1); plugin +
1297
- // entrySkill are the meaningful identity fields there).
1292
+ // body for every platform. Marketplace identity follows the distribution
1293
+ // declaration: claude/codex require it via the registry schema fields;
1294
+ // kimi/codebuddy tolerate an optional declaration (codebuddy defaults to
1295
+ // its unified marketplace constant downstream when undeclared).
1296
+ // Undeclared stays absent so legacy frozen plans remain byte-identical.
1298
1297
  const frozenUnitDists = frozenDistributions?.get(unit.id) ?? null;
1299
1298
  for (const platform of PLATFORMS) {
1300
1299
  const dist = frozenUnitDists
1301
1300
  ? frozenUnitDists.find((d) => d.type === platform.distributionType)
1302
1301
  : (unit.distributions ?? []).find((d) => d.type === platform.distributionType);
1303
1302
  if (!dist) continue;
1304
- const requiresMarketplace = platform.schemaRequiredFields.includes('marketplace');
1305
1303
  const timeoutMs = Number.isInteger(dist.timeoutMs) ? dist.timeoutMs : 300000;
1306
1304
  // External independent marketplace form: the distribution declares
1307
1305
  // marketplaceRepo, so the install targets the external marketplace repo
@@ -1350,7 +1348,8 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1350
1348
  parameters: {
1351
1349
  consumer: platform.id,
1352
1350
  plugin: dist.plugin,
1353
- ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1351
+ ...(dist.marketplace !== undefined ? { marketplace: dist.marketplace } : {}),
1352
+ ...(dist.marketplaceSource !== undefined ? { marketplaceSource: dist.marketplaceSource } : {}),
1354
1353
  repo: externalMarketplace ? dist.marketplaceRepo : unit.publicRepo,
1355
1354
  version,
1356
1355
  entrySkill: dist.entrySkill,
@@ -1377,7 +1376,8 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1377
1376
  expected: {
1378
1377
  installed: true,
1379
1378
  plugin: dist.plugin,
1380
- ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1379
+ ...(dist.marketplace !== undefined ? { marketplace: dist.marketplace } : {}),
1380
+ ...(dist.marketplaceSource !== undefined ? { marketplaceSource: dist.marketplaceSource } : {}),
1381
1381
  version,
1382
1382
  entrySkill: dist.entrySkill,
1383
1383
  ...(externalMarketplace ? { marketplaceLocation: 'external', repo: dist.marketplaceRepo } : {}),
@@ -1532,13 +1532,17 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1532
1532
  // non-production loop above plus the production-only bindings
1533
1533
  // (ref/snapshotPath/manifestDigest parameters; consumer/repo/ref/
1534
1534
  // entrySkillFound/manifestDigest expected). Marketplace identity follows
1535
- // the registry's schema required fields kimi carries none (MINOR-1).
1535
+ // the distribution declaration, same as the non-production loop.
1536
1536
  for (const platform of PLATFORMS) {
1537
1537
  const dist = frozenUnitDists
1538
1538
  ? frozenUnitDists.find((d) => d.type === platform.distributionType)
1539
1539
  : (unit.distributions ?? []).find((d) => d.type === platform.distributionType);
1540
1540
  if (!dist) continue;
1541
- const requiresMarketplace = platform.schemaRequiredFields.includes('marketplace');
1541
+ // Marketplace identity follows the distribution declaration: claude/codex
1542
+ // require it via the registry schema fields; kimi/codebuddy tolerate an
1543
+ // optional declaration (codebuddy defaults to its unified marketplace
1544
+ // constant downstream when undeclared). Undeclared stays absent so
1545
+ // legacy frozen plans remain byte-identical.
1542
1546
  const timeoutMs = Number.isInteger(dist.timeoutMs) ? dist.timeoutMs : 300000;
1543
1547
  // External independent marketplace form: the install targets the external
1544
1548
  // marketplace repo with the add-ref + marketplaceCommitSha frozen online
@@ -1583,7 +1587,8 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1583
1587
  parameters: {
1584
1588
  consumer: platform.id,
1585
1589
  plugin: dist.plugin,
1586
- ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1590
+ ...(dist.marketplace !== undefined ? { marketplace: dist.marketplace } : {}),
1591
+ ...(dist.marketplaceSource !== undefined ? { marketplaceSource: dist.marketplaceSource } : {}),
1587
1592
  repo: externalMarketplace ? dist.marketplaceRepo : unit.publicRepo,
1588
1593
  ref: externalMarketplace ? freeze.ref : resolvedTag,
1589
1594
  version: unitVersion,
@@ -1622,7 +1627,8 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1622
1627
  installed: true,
1623
1628
  consumer: platform.id,
1624
1629
  plugin: dist.plugin,
1625
- ...(requiresMarketplace ? { marketplace: dist.marketplace } : {}),
1630
+ ...(dist.marketplace !== undefined ? { marketplace: dist.marketplace } : {}),
1631
+ ...(dist.marketplaceSource !== undefined ? { marketplaceSource: dist.marketplaceSource } : {}),
1626
1632
  repo: externalMarketplace ? dist.marketplaceRepo : unit.publicRepo,
1627
1633
  version: unitVersion,
1628
1634
  ref: externalMarketplace ? freeze.ref : resolvedTag,
@@ -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 };
@@ -751,9 +754,10 @@ export function validatePlanActionCompleteness(plan, options = {}) {
751
754
  // consumer id, and — via the schema required fields — whether marketplace
752
755
  // identity is a required binding). Platforms without a required
753
756
  // marketplace (kimi, MINOR-1: Kimi Code has an interactive marketplace
754
- // but no non-interactive install API) tolerate a declared marketplace as
755
- // an optional legacy value but never bind it as a required condition, in
756
- // parameters or in expected.
757
+ // but no non-interactive install API; codebuddy, whose unified marketplace
758
+ // defaults to a constant but may be overridden by the distribution)
759
+ // tolerate a declared marketplace as an optional value but never bind it
760
+ // as a required condition, in parameters or in expected.
757
761
  for (const platform of PLATFORMS) {
758
762
  const dist = distributions.find((d) => d.type === platform.distributionType);
759
763
  if (!dist) continue;
@@ -1148,7 +1152,7 @@ export function validatePlanActionCompleteness(plan, options = {}) {
1148
1152
  // 不能只比较两个可一起篡改的字符串;必须重新由 distribution 的 installationContract 计算并验证
1149
1153
  if (hasFieldGroup && dist.installationContract) {
1150
1154
  // 从 distribution 的 installationContract 重新计算摘要
1151
- const recomputedDigest = sha256Hex(canonicalJson(dist.installationContract));
1155
+ const recomputedDigest = digestDocument(dist.installationContract);
1152
1156
  const actionDigest = action.parameters?.installationContractDigest;
1153
1157
  const distDigest = dist.installationContractDigest;
1154
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);