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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +17 -6
- package/README.zh-CN.md +16 -5
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +154 -0
- package/adapters/claude/bin/kernel-protocol.json +60 -0
- package/adapters/claude/bin/registry.json +165 -0
- package/adapters/claude/bin/release-skill.bundle.mjs +3838 -1445
- package/adapters/claude/bin/rules.json +104 -0
- package/adapters/claude/skills/release-assess/SKILL.md +2 -0
- package/adapters/claude/skills/release-help/SKILL.md +3 -1
- package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
- package/adapters/claude/skills/release-verify/SKILL.md +2 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +154 -0
- package/adapters/codex/bin/kernel-protocol.json +60 -0
- package/adapters/codex/bin/registry.json +165 -0
- package/adapters/codex/bin/release-skill.bundle.mjs +3838 -1445
- package/adapters/codex/bin/rules.json +104 -0
- package/adapters/codex/skills/release-assess/SKILL.md +2 -0
- package/adapters/codex/skills/release-help/SKILL.md +3 -1
- package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
- package/adapters/codex/skills/release-verify/SKILL.md +2 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +154 -0
- package/adapters/kimi/bin/kernel-protocol.json +60 -0
- package/adapters/kimi/bin/registry.json +165 -0
- package/adapters/kimi/bin/release-skill.bundle.mjs +3838 -1445
- package/adapters/kimi/bin/rules.json +104 -0
- package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
- package/adapters/kimi/skills/release-help/SKILL.md +3 -1
- package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
- package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +154 -0
- package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
- package/adapters/workbuddy/bin/registry.json +165 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3838 -1445
- package/adapters/workbuddy/bin/rules.json +104 -0
- package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
- package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
- package/bin/error-codes.json +154 -0
- package/bin/kernel-protocol.json +60 -0
- package/bin/registry.json +165 -0
- package/bin/release-skill.bundle.mjs +3838 -1445
- package/bin/rules.json +104 -0
- package/package.json +3 -1
- package/scripts/build-bundle.mjs +159 -5
- package/scripts/sync-public-files.mjs +5 -0
- package/skills/release-assess/SKILL.md +2 -0
- package/skills/release-help/SKILL.md +3 -1
- package/skills/release-prepare/SKILL.md +2 -2
- package/skills/release-verify/SKILL.md +2 -0
- package/skills-src/release-assess/SKILL.md +2 -0
- package/skills-src/release-help/SKILL.md +3 -1
- package/skills-src/release-prepare/SKILL.md +2 -2
- package/skills-src/release-verify/SKILL.md +2 -0
- package/src/adapters/contract.mjs +10 -10
- package/src/artifacts/entry.mjs +4 -1
- package/src/artifacts/producer-registry.mjs +9 -6
- package/src/artifacts/project-lock.mjs +324 -214
- package/src/commands/assess.mjs +60 -2
- package/src/commands/attest.mjs +11 -0
- package/src/core/approval.mjs +5 -2
- package/src/core/digest.mjs +47 -12
- package/src/core/foundation-inflight.mjs +39 -0
- package/src/core/hook-cache.mjs +3 -0
- package/src/core/installation-contract.mjs +4 -2
- package/src/core/plan.mjs +30 -27
- package/src/core/run.mjs +22 -36
- package/src/core/skill-resource-closure.mjs +6 -4
- package/src/core/trusted-resource.mjs +64 -15
- package/src/producers/build-adapters.mjs +27 -9
- package/src/producers/render-public-assets.mjs +12 -7
- package/src/producers/sync-skills.mjs +12 -7
- package/src/snapshot/public-map.mjs +10 -2
package/src/commands/assess.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
package/src/commands/attest.mjs
CHANGED
|
@@ -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 {
|
package/src/core/approval.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
26
26
|
import { computePlanDigest } from './plan.mjs';
|
|
27
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/src/core/digest.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
66
|
+
return digestBytes(input);
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
// ---- internal helpers (not exported) ----
|
|
49
70
|
|
|
50
71
|
/**
|
|
51
|
-
*
|
|
72
|
+
* 宽松输入域归一化:把本地历史接受的、非严格 JSON 的值转换为与
|
|
73
|
+
* `JSON.stringify` 一致的严格 JSON 兼容值,再交由 Foundation 权威序列化。
|
|
74
|
+
* 该函数产生的字节序列与迁移前的本地 canonicalise + JSON.stringify 完全一致。
|
|
52
75
|
*
|
|
53
76
|
* @param {*} value
|
|
54
77
|
* @returns {*}
|
|
55
78
|
*/
|
|
56
|
-
function
|
|
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) =>
|
|
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
|
|
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
|
-
//
|
|
82
|
-
if (v === undefined) continue;
|
|
83
|
-
sorted[key] =
|
|
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
|
-
//
|
|
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
|
+
};
|
package/src/core/hook-cache.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
|
338
|
-
} catch (
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
431
|
+
result.receiptDigest = digestDocument(receiptProjection(result));
|
|
430
432
|
return result;
|
|
431
433
|
}
|