release-skill 0.4.2 → 0.5.1
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 +44 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +10 -7
- package/README.zh-CN.md +10 -7
- 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 +179 -0
- package/adapters/claude/bin/release-skill.bundle.mjs +37609 -23014
- 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 +179 -0
- package/adapters/codex/bin/release-skill.bundle.mjs +37609 -23014
- 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 +179 -0
- package/adapters/kimi/bin/release-skill.bundle.mjs +37609 -23014
- 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 +179 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +37609 -23014
- 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 +179 -0
- package/bin/release-skill.bundle.mjs +37609 -23014
- 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/adapters/plugin-marketplace.mjs +48 -4
- package/src/adapters/push-snapshot.mjs +12 -4
- 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 +231 -2
- package/src/commands/attest.mjs +11 -0
- package/src/commands/hooks.mjs +6 -1
- package/src/commands/prepare.mjs +83 -1
- package/src/core/approval.mjs +5 -2
- package/src/core/baseline.mjs +11 -1
- package/src/core/digest.mjs +47 -12
- package/src/core/foundation-inflight.mjs +46 -0
- package/src/core/hook-cache.mjs +3 -0
- package/src/core/installation-contract.mjs +4 -2
- package/src/core/plan.mjs +42 -29
- 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,14 +687,193 @@ 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
|
|
|
651
709
|
return gaps;
|
|
652
710
|
}
|
|
653
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Parse a semver-ish version string into comparable components.
|
|
714
|
+
*
|
|
715
|
+
* Accepts `major.minor.patch` with an optional `-prerelease` suffix (which
|
|
716
|
+
* sorts strictly below the same release triple). Returns null for anything
|
|
717
|
+
* that is not a version-shaped tag (candidate names like `0.1.27-candidate.2`
|
|
718
|
+
* parse fine; non-version tags are skipped).
|
|
719
|
+
*
|
|
720
|
+
* @param {string} version
|
|
721
|
+
* @returns {{ major: number, minor: number, patch: number, prerelease: string|null } | null}
|
|
722
|
+
*/
|
|
723
|
+
export function parseSemverVersion(version) {
|
|
724
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version);
|
|
725
|
+
if (!match) return null;
|
|
726
|
+
return {
|
|
727
|
+
major: Number(match[1]),
|
|
728
|
+
minor: Number(match[2]),
|
|
729
|
+
patch: Number(match[3]),
|
|
730
|
+
prerelease: match[4] ?? null,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Compare two parsed semver values. Returns a negative/zero/positive number.
|
|
736
|
+
*
|
|
737
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} a
|
|
738
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} b
|
|
739
|
+
* @returns {number}
|
|
740
|
+
*/
|
|
741
|
+
export function compareSemverVersions(a, b) {
|
|
742
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
743
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
744
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
745
|
+
if (a.prerelease && !b.prerelease) return -1;
|
|
746
|
+
if (!a.prerelease && b.prerelease) return 1;
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Describe a version-sequence gap between an immediate predecessor and the
|
|
752
|
+
* target version, or null when the step is contiguous.
|
|
753
|
+
*
|
|
754
|
+
* A gap means some version between the two was never released (e.g. the
|
|
755
|
+
* historical 0.1.1 -> 0.1.3 jump with no v0.1.2 tag). Pre-release steps
|
|
756
|
+
* never count as gaps by themselves.
|
|
757
|
+
*
|
|
758
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} prev
|
|
759
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} target
|
|
760
|
+
* @returns {string|null}
|
|
761
|
+
*/
|
|
762
|
+
export function describeVersionSequenceGap(prev, target) {
|
|
763
|
+
if (prev.major !== target.major) {
|
|
764
|
+
return prev.major + 1 < target.major
|
|
765
|
+
? `major ${prev.major}.x.x -> ${target.major}.x.x`
|
|
766
|
+
: null;
|
|
767
|
+
}
|
|
768
|
+
if (prev.minor !== target.minor) {
|
|
769
|
+
return prev.minor + 1 < target.minor
|
|
770
|
+
? `minor ${prev.major}.${prev.minor}.x -> ${target.major}.${target.minor}.x`
|
|
771
|
+
: null;
|
|
772
|
+
}
|
|
773
|
+
if (prev.patch !== target.patch) {
|
|
774
|
+
return prev.patch + 1 < target.patch
|
|
775
|
+
? `patch ${prev.major}.${prev.minor}.${prev.patch} -> ${target.major}.${target.minor}.${target.patch}`
|
|
776
|
+
: null;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Build a regex that extracts the version from a tag name following the
|
|
783
|
+
* unit's tagTemplate (`{version}` placeholder, regex-special characters
|
|
784
|
+
* escaped).
|
|
785
|
+
*
|
|
786
|
+
* @param {string} tagTemplate
|
|
787
|
+
* @returns {RegExp}
|
|
788
|
+
*/
|
|
789
|
+
export function buildTagVersionRegex(tagTemplate) {
|
|
790
|
+
const escaped = tagTemplate
|
|
791
|
+
.split('{version}')
|
|
792
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
793
|
+
.join('(.+)');
|
|
794
|
+
return new RegExp(`^${escaped}$`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Check the public repository's release tag sequence for version jumps
|
|
799
|
+
* (0.5.1 chain hardening): when the immediate predecessor release is more
|
|
800
|
+
* than one version step below the current target version, register a
|
|
801
|
+
* warning-level gap `VERSION_SEQUENCE_GAP` (e.g. 0.1.1 -> 0.1.3 with no
|
|
802
|
+
* v0.1.2 tag). This never blocks: it records the observation for the human
|
|
803
|
+
* release planner.
|
|
804
|
+
*
|
|
805
|
+
* @param {string} root - Project root.
|
|
806
|
+
* @param {Object} config - The loaded project config.
|
|
807
|
+
* @returns {Promise<Object[]>} Array of warning gap entries.
|
|
808
|
+
*/
|
|
809
|
+
async function checkReleaseTagSequence(root, config) {
|
|
810
|
+
const gaps = [];
|
|
811
|
+
for (const unit of config.releaseUnits ?? []) {
|
|
812
|
+
const tagTemplate = unit.version?.tagTemplate;
|
|
813
|
+
if (!tagTemplate || !unit.publicRepo) continue;
|
|
814
|
+
|
|
815
|
+
const versionRegex = buildTagVersionRegex(tagTemplate);
|
|
816
|
+
const tagPattern = tagTemplate.replace('{version}', '*');
|
|
817
|
+
|
|
818
|
+
let stdout;
|
|
819
|
+
try {
|
|
820
|
+
({ stdout } = await execFile(
|
|
821
|
+
'git',
|
|
822
|
+
['ls-remote', `https://github.com/${unit.publicRepo}.git`, `refs/tags/${tagPattern}`],
|
|
823
|
+
{ cwd: root, shell: false, encoding: 'utf8', timeout: 15_000 },
|
|
824
|
+
));
|
|
825
|
+
} catch {
|
|
826
|
+
// Tag enumeration failure (network/auth/repo absent) is not a version
|
|
827
|
+
// gap and must not block; the remote-prerequisites npm check already
|
|
828
|
+
// reports unreachable registries where relevant.
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
let targetVersion;
|
|
833
|
+
try {
|
|
834
|
+
const pkg = JSON.parse(await readFile(resolve(root, unit.source, 'package.json'), 'utf8'));
|
|
835
|
+
targetVersion = pkg.version;
|
|
836
|
+
} catch {
|
|
837
|
+
continue; // package metadata issues are reported elsewhere
|
|
838
|
+
}
|
|
839
|
+
const targetParsed = parseSemverVersion(targetVersion);
|
|
840
|
+
if (!targetParsed) continue;
|
|
841
|
+
|
|
842
|
+
let previousParsed = null;
|
|
843
|
+
let previousTag = null;
|
|
844
|
+
for (const line of stdout.trim().split('\n')) {
|
|
845
|
+
if (!line) continue;
|
|
846
|
+
const tagName = line.split('\t')[1]?.replace(/^refs\/tags\//, '');
|
|
847
|
+
if (!tagName) continue;
|
|
848
|
+
const versionMatch = versionRegex.exec(tagName);
|
|
849
|
+
if (!versionMatch) continue;
|
|
850
|
+
const parsed = parseSemverVersion(versionMatch[1]);
|
|
851
|
+
if (!parsed) continue;
|
|
852
|
+
// Prereleases never qualify as the "previous release" predecessor.
|
|
853
|
+
if (parsed.prerelease) continue;
|
|
854
|
+
if (compareSemverVersions(parsed, targetParsed) >= 0) continue;
|
|
855
|
+
if (previousParsed === null || compareSemverVersions(parsed, previousParsed) > 0) {
|
|
856
|
+
previousParsed = parsed;
|
|
857
|
+
previousTag = tagName;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!previousParsed) continue;
|
|
862
|
+
const gap = describeVersionSequenceGap(previousParsed, targetParsed);
|
|
863
|
+
if (gap) {
|
|
864
|
+
gaps.push(createGap({
|
|
865
|
+
scope: GapScope.PROJECT,
|
|
866
|
+
category: GapCategory.REMOTE,
|
|
867
|
+
severity: Severity.WARNING,
|
|
868
|
+
code: 'VERSION_SEQUENCE_GAP',
|
|
869
|
+
message: `发布单元 "${unit.id}" 的版本序列存在跳号(${gap},上一发布标签 ${previousTag},目标版本 ${targetVersion}):请确认中间版本未发布是否是有意为之;发布链仍会以链式历史推进,但跳过的版本不会补造标签`,
|
|
870
|
+
file: '.release-skill/project.yaml',
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return gaps;
|
|
875
|
+
}
|
|
876
|
+
|
|
654
877
|
/**
|
|
655
878
|
* Perform a basic README structural check.
|
|
656
879
|
*
|
|
@@ -909,6 +1132,12 @@ export async function assessProject(options) {
|
|
|
909
1132
|
const remoteGaps = await checkRemotePrerequisites(root, config, offline);
|
|
910
1133
|
allGaps.push(...remoteGaps);
|
|
911
1134
|
|
|
1135
|
+
// --- 7b. Release tag sequence (online only; warning, never blocking) ---
|
|
1136
|
+
if (!offline) {
|
|
1137
|
+
const tagSequenceGaps = await checkReleaseTagSequence(root, config);
|
|
1138
|
+
allGaps.push(...tagSequenceGaps);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
912
1141
|
// --- 8. README structure check ---
|
|
913
1142
|
const readmeGaps = await checkReadmeStructure(root, config);
|
|
914
1143
|
allGaps.push(...readmeGaps);
|
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/commands/hooks.mjs
CHANGED
|
@@ -28,7 +28,12 @@ export async function validateDeclaredHooks(options = {}) {
|
|
|
28
28
|
clock: () => new Date().toISOString(),
|
|
29
29
|
});
|
|
30
30
|
const startedAt = new Date().toISOString();
|
|
31
|
-
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
31
|
+
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
32
|
+
hookCache,
|
|
33
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): same semantics as
|
|
34
|
+
// prepare — allowlisted keys are read from this explicit map only.
|
|
35
|
+
env: process.env,
|
|
36
|
+
});
|
|
32
37
|
return {
|
|
33
38
|
command: 'hooks validate',
|
|
34
39
|
status: 'PASSED',
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -243,6 +243,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
243
243
|
* @param {object} [options]
|
|
244
244
|
* @param {boolean} [options.hookCache=true] - When false (--no-hook-cache),
|
|
245
245
|
* every hook runs in full and the cache is neither read nor written.
|
|
246
|
+
* @param {Record<string, string>} [options.env] - Explicit environment map
|
|
247
|
+
* merged into the hook context (`hookFn(hook, { root, env })`). The hook
|
|
248
|
+
* runner's `buildFilteredEnv` reads `envAllowlist` keys exclusively from
|
|
249
|
+
* this map (never from process.env), so the caller decides what is
|
|
250
|
+
* injectable. Defaults to process.env at the prepare call site, which makes
|
|
251
|
+
* allowlisted keys exported by the invoking shell reach the hook
|
|
252
|
+
* subprocess.
|
|
246
253
|
* @returns {Promise<void>}
|
|
247
254
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
|
|
248
255
|
* throws, or declares a cacheInputs glob that matches no file.
|
|
@@ -294,7 +301,10 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
294
301
|
|
|
295
302
|
let result;
|
|
296
303
|
try {
|
|
297
|
-
result = await hookFn(hook, {
|
|
304
|
+
result = await hookFn(hook, {
|
|
305
|
+
root,
|
|
306
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
307
|
+
});
|
|
298
308
|
} catch (err) {
|
|
299
309
|
await evidence.append({
|
|
300
310
|
phase: 'hooks',
|
|
@@ -1850,6 +1860,11 @@ export async function prepareRelease(options) {
|
|
|
1850
1860
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1851
1861
|
await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
1852
1862
|
hookCache: options.hookCache,
|
|
1863
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
|
|
1864
|
+
// reads envAllowlist keys exclusively from context.env, so the invoking
|
|
1865
|
+
// shell's environment is injected here explicitly. Allowlist semantics
|
|
1866
|
+
// are unchanged — only allowlisted keys from this map reach the child.
|
|
1867
|
+
env: options.env ?? process.env,
|
|
1853
1868
|
});
|
|
1854
1869
|
await evidence.append({ phase: 'hooks', status: 'completed' });
|
|
1855
1870
|
|
|
@@ -2077,6 +2092,29 @@ export async function prepareRelease(options) {
|
|
|
2077
2092
|
}
|
|
2078
2093
|
};
|
|
2079
2094
|
const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
|
|
2095
|
+
// CHAIN_GAP detection (0.5.1 chain hardening): when a unit declares
|
|
2096
|
+
// previousPublicBaseline.mode=none (first-release semantics) but the
|
|
2097
|
+
// public repository already carries release tags for the unit's
|
|
2098
|
+
// tagTemplate, a subsequent release pretending to be the first would
|
|
2099
|
+
// freeze a plan whose push-snapshot creates a new orphan root commit.
|
|
2100
|
+
// Detection is online-only: `git ls-remote` on the tag pattern
|
|
2101
|
+
// (tagTemplate with {version} → *). Any match proves a prior release.
|
|
2102
|
+
const defaultPriorReleaseTagDetector = async (repo, tagPattern, { githubHost = 'github.com' } = {}) => {
|
|
2103
|
+
try {
|
|
2104
|
+
const { stdout } = await execFile(
|
|
2105
|
+
'git',
|
|
2106
|
+
['ls-remote', `https://${githubHost}/${repo}.git`, `refs/tags/${tagPattern}`],
|
|
2107
|
+
{ shell: false, encoding: 'utf8', timeout: 30000 },
|
|
2108
|
+
);
|
|
2109
|
+
return { found: stdout.trim().length > 0 };
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
// Network/auth failure: unknown, not "no prior release". The caller
|
|
2112
|
+
// records a warning and continues (consistent with the bound-baseline
|
|
2113
|
+
// observer's unknown status); it never silently proves first release.
|
|
2114
|
+
return { error: err.message };
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
const detectPriorReleaseTags = options.detectPriorReleaseTagsFn ?? defaultPriorReleaseTagDetector;
|
|
2080
2118
|
const unitBaselineResults = new Map();
|
|
2081
2119
|
for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
|
|
2082
2120
|
const unit = configUnits[unitIndex];
|
|
@@ -2120,6 +2158,50 @@ export async function prepareRelease(options) {
|
|
|
2120
2158
|
});
|
|
2121
2159
|
|
|
2122
2160
|
if (ppbConfig.mode === "none") {
|
|
2161
|
+
// Chain-integrity gate (0.5.1, CHAIN_GAP): a production online
|
|
2162
|
+
// prepare must not freeze a first-release plan for a repository that
|
|
2163
|
+
// has already published. This is the orphan-root channel that caused
|
|
2164
|
+
// the synthetic 2000-01-01 root commits in the historical public
|
|
2165
|
+
// repositories: first releases used mode=none, push-snapshot degraded
|
|
2166
|
+
// to create-release-branch with no parent, and every later release
|
|
2167
|
+
// started a fresh lineage. Fail closed and demand a bound baseline
|
|
2168
|
+
// pointing at the previous release commit instead.
|
|
2169
|
+
if (production && !offline) {
|
|
2170
|
+
const tagTemplate = unit.version?.tagTemplate;
|
|
2171
|
+
const tagPattern = tagTemplate ? tagTemplate.replace('{version}', '*') : null;
|
|
2172
|
+
if (tagPattern) {
|
|
2173
|
+
const detection = await detectPriorReleaseTags(unit.publicRepo, tagPattern, {
|
|
2174
|
+
githubHost: productionGithubHost,
|
|
2175
|
+
});
|
|
2176
|
+
if (detection.found) {
|
|
2177
|
+
await evidence.append({
|
|
2178
|
+
phase: "previous-public-baseline",
|
|
2179
|
+
unitId: unit.id,
|
|
2180
|
+
status: "blocking",
|
|
2181
|
+
reason: "CHAIN_GAP",
|
|
2182
|
+
repo: unit.publicRepo,
|
|
2183
|
+
tagPattern,
|
|
2184
|
+
guidance: "非首次发布:必须把 previousPublicBaseline 绑定到上一发布提交(mode=bound),不能以 mode=none 制造新的孤儿根提交",
|
|
2185
|
+
});
|
|
2186
|
+
throw new ReleaseError(
|
|
2187
|
+
GATE_FAILED,
|
|
2188
|
+
`unit "${unit.id}" previousPublicBaseline.mode=none but the public repository already has release tags matching "${tagPattern}" (CHAIN_GAP): bind the previous public release commit as a bound baseline`,
|
|
2189
|
+
{ unitId: unit.id, reason: 'CHAIN_GAP', repo: unit.publicRepo, tagPattern },
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
if (detection.error) {
|
|
2193
|
+
await evidence.append({
|
|
2194
|
+
phase: "previous-public-baseline",
|
|
2195
|
+
unitId: unit.id,
|
|
2196
|
+
status: "warning",
|
|
2197
|
+
reason: "CHAIN_GAP_DETECTION_FAILED",
|
|
2198
|
+
repo: unit.publicRepo,
|
|
2199
|
+
tagPattern,
|
|
2200
|
+
error: detection.error,
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2123
2205
|
unitBaselineResults.set(unit.id, {
|
|
2124
2206
|
mode: "none",
|
|
2125
2207
|
status: "consistent",
|
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/baseline.mjs
CHANGED
|
@@ -146,7 +146,17 @@ function parseStatusPorcelainZ(statusOut) {
|
|
|
146
146
|
* @returns {Promise<string>} Hex-encoded SHA-256 digest.
|
|
147
147
|
*/
|
|
148
148
|
async function computeWorkspaceDigest(root) {
|
|
149
|
-
|
|
149
|
+
// 64 MiB upper bound (official fix 39c631f): `git ls-files -s -z` emits one
|
|
150
|
+
// line per tracked file and routinely exceeds Node's default 1 MiB
|
|
151
|
+
// execFile maxBuffer on large repositories (measured 8494 tracked files →
|
|
152
|
+
// 1,519,151 B stdout). Large enough for any realistic baseline, small
|
|
153
|
+
// enough to fail closed before exhausting memory.
|
|
154
|
+
const opts = {
|
|
155
|
+
cwd: root,
|
|
156
|
+
shell: false,
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
159
|
+
};
|
|
150
160
|
|
|
151
161
|
const [
|
|
152
162
|
{ stdout: stagedOut },
|
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,46 @@
|
|
|
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
|
+
* 0.5.1 依赖提升:skill-family-contracts / skill-family-harness-node 由 0.4.0
|
|
17
|
+
* 提升至 0.5.0(npm latest,2026-08-16 发布)。harness-node 0.5.0 导出面已逐项
|
|
18
|
+
* 核对:publishFileExclusive(atomic.mjs)、acquireFilesystemLock /
|
|
19
|
+
* inspectFilesystemLock / releaseFilesystemLock / recoverFilesystemLock
|
|
20
|
+
* (token-lock.mjs)、HARNESS_ERROR_KINDS(errors.mjs)均在 index.mjs 导出面,
|
|
21
|
+
* 包名 import 无需改动。
|
|
22
|
+
*
|
|
23
|
+
* 发布形态:包名 import 可直接用于包内 src(node_modules 解析),bundle
|
|
24
|
+
* (bin/release-skill.bundle.mjs,esbuild 内联)保持自包含。
|
|
25
|
+
*
|
|
26
|
+
* vendor/foundation-pin 与 scripts/materialize-foundation-pin.mjs 因语义失效
|
|
27
|
+
* (pin 指向工作树在途字节)按 D4 裁决退役并归档,不再存在检查期漂移证据。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
publishFileExclusive,
|
|
32
|
+
acquireFilesystemLock,
|
|
33
|
+
inspectFilesystemLock,
|
|
34
|
+
releaseFilesystemLock,
|
|
35
|
+
recoverFilesystemLock,
|
|
36
|
+
HARNESS_ERROR_KINDS,
|
|
37
|
+
} from 'skill-family-harness-node';
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
publishFileExclusive,
|
|
41
|
+
acquireFilesystemLock,
|
|
42
|
+
inspectFilesystemLock,
|
|
43
|
+
releaseFilesystemLock,
|
|
44
|
+
recoverFilesystemLock,
|
|
45
|
+
HARNESS_ERROR_KINDS,
|
|
46
|
+
};
|
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
|
/**
|