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.
Files changed (91) 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 +44 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +10 -7
  10. package/README.zh-CN.md +10 -7
  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 +179 -0
  16. package/adapters/claude/bin/release-skill.bundle.mjs +37609 -23014
  17. package/adapters/claude/bin/rules.json +104 -0
  18. package/adapters/claude/skills/release-assess/SKILL.md +2 -0
  19. package/adapters/claude/skills/release-help/SKILL.md +3 -1
  20. package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
  21. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/error-codes.json +154 -0
  24. package/adapters/codex/bin/kernel-protocol.json +60 -0
  25. package/adapters/codex/bin/registry.json +179 -0
  26. package/adapters/codex/bin/release-skill.bundle.mjs +37609 -23014
  27. package/adapters/codex/bin/rules.json +104 -0
  28. package/adapters/codex/skills/release-assess/SKILL.md +2 -0
  29. package/adapters/codex/skills/release-help/SKILL.md +3 -1
  30. package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
  31. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  32. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  33. package/adapters/kimi/bin/error-codes.json +154 -0
  34. package/adapters/kimi/bin/kernel-protocol.json +60 -0
  35. package/adapters/kimi/bin/registry.json +179 -0
  36. package/adapters/kimi/bin/release-skill.bundle.mjs +37609 -23014
  37. package/adapters/kimi/bin/rules.json +104 -0
  38. package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
  39. package/adapters/kimi/skills/release-help/SKILL.md +3 -1
  40. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
  41. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  42. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  43. package/adapters/workbuddy/bin/error-codes.json +154 -0
  44. package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
  45. package/adapters/workbuddy/bin/registry.json +179 -0
  46. package/adapters/workbuddy/bin/release-skill.bundle.mjs +37609 -23014
  47. package/adapters/workbuddy/bin/rules.json +104 -0
  48. package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
  49. package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
  50. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
  51. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  52. package/bin/error-codes.json +154 -0
  53. package/bin/kernel-protocol.json +60 -0
  54. package/bin/registry.json +179 -0
  55. package/bin/release-skill.bundle.mjs +37609 -23014
  56. package/bin/rules.json +104 -0
  57. package/package.json +3 -1
  58. package/scripts/build-bundle.mjs +159 -5
  59. package/scripts/sync-public-files.mjs +5 -0
  60. package/skills/release-assess/SKILL.md +2 -0
  61. package/skills/release-help/SKILL.md +3 -1
  62. package/skills/release-prepare/SKILL.md +2 -2
  63. package/skills/release-verify/SKILL.md +2 -0
  64. package/skills-src/release-assess/SKILL.md +2 -0
  65. package/skills-src/release-help/SKILL.md +3 -1
  66. package/skills-src/release-prepare/SKILL.md +2 -2
  67. package/skills-src/release-verify/SKILL.md +2 -0
  68. package/src/adapters/contract.mjs +10 -10
  69. package/src/adapters/plugin-marketplace.mjs +48 -4
  70. package/src/adapters/push-snapshot.mjs +12 -4
  71. package/src/artifacts/entry.mjs +4 -1
  72. package/src/artifacts/producer-registry.mjs +9 -6
  73. package/src/artifacts/project-lock.mjs +324 -214
  74. package/src/commands/assess.mjs +231 -2
  75. package/src/commands/attest.mjs +11 -0
  76. package/src/commands/hooks.mjs +6 -1
  77. package/src/commands/prepare.mjs +83 -1
  78. package/src/core/approval.mjs +5 -2
  79. package/src/core/baseline.mjs +11 -1
  80. package/src/core/digest.mjs +47 -12
  81. package/src/core/foundation-inflight.mjs +46 -0
  82. package/src/core/hook-cache.mjs +3 -0
  83. package/src/core/installation-contract.mjs +4 -2
  84. package/src/core/plan.mjs +42 -29
  85. package/src/core/run.mjs +22 -36
  86. package/src/core/skill-resource-closure.mjs +6 -4
  87. package/src/core/trusted-resource.mjs +64 -15
  88. package/src/producers/build-adapters.mjs +27 -9
  89. package/src/producers/render-public-assets.mjs +12 -7
  90. package/src/producers/sync-skills.mjs +12 -7
  91. 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,14 +687,193 @@ 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
 
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);
@@ -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 {
@@ -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, { hookCache });
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',
@@ -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, { root });
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",
@@ -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
  }
@@ -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
- const opts = { cwd: root, shell: false, encoding: 'utf8' };
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 },
@@ -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,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
+ };
@@ -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
  /**