release-skill 0.5.0 → 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 (49) 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 +18 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +10 -18
  10. package/README.zh-CN.md +10 -18
  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 +1 -1
  14. package/adapters/claude/bin/kernel-protocol.json +1 -1
  15. package/adapters/claude/bin/registry.json +15 -1
  16. package/adapters/claude/bin/release-skill.bundle.mjs +35052 -22850
  17. package/adapters/claude/bin/rules.json +1 -1
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/error-codes.json +1 -1
  20. package/adapters/codex/bin/kernel-protocol.json +1 -1
  21. package/adapters/codex/bin/registry.json +15 -1
  22. package/adapters/codex/bin/release-skill.bundle.mjs +35052 -22850
  23. package/adapters/codex/bin/rules.json +1 -1
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/error-codes.json +1 -1
  26. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  27. package/adapters/kimi/bin/registry.json +15 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +35052 -22850
  29. package/adapters/kimi/bin/rules.json +1 -1
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/error-codes.json +1 -1
  32. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  33. package/adapters/workbuddy/bin/registry.json +15 -1
  34. package/adapters/workbuddy/bin/release-skill.bundle.mjs +35052 -22850
  35. package/adapters/workbuddy/bin/rules.json +1 -1
  36. package/bin/error-codes.json +1 -1
  37. package/bin/kernel-protocol.json +1 -1
  38. package/bin/registry.json +15 -1
  39. package/bin/release-skill.bundle.mjs +35052 -22850
  40. package/bin/rules.json +1 -1
  41. package/package.json +3 -3
  42. package/src/adapters/plugin-marketplace.mjs +48 -4
  43. package/src/adapters/push-snapshot.mjs +12 -4
  44. package/src/commands/assess.mjs +171 -0
  45. package/src/commands/hooks.mjs +6 -1
  46. package/src/commands/prepare.mjs +83 -1
  47. package/src/core/baseline.mjs +11 -1
  48. package/src/core/foundation-inflight.mjs +7 -0
  49. package/src/core/plan.mjs +12 -2
package/bin/rules.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "kind": "skill-family.contracts.rules",
4
- "contractsVersion": "1.4.0",
4
+ "contractsVersion": "1.5.0",
5
5
  "budget": {
6
6
  "firstVersionMax": 20,
7
7
  "absoluteMax": 30,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -76,8 +76,8 @@
76
76
  "ajv-formats": "3.0.1",
77
77
  "libnpmpublish": "11.1.0",
78
78
  "npm-registry-fetch": "18.0.2",
79
- "skill-family-contracts": "0.4.0",
80
- "skill-family-harness-node": "0.4.0",
79
+ "skill-family-contracts": "0.5.0",
80
+ "skill-family-harness-node": "0.5.0",
81
81
  "yaml": "2.9.0"
82
82
  },
83
83
  "devDependencies": {
@@ -2179,10 +2179,34 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2179
2179
  // pluginRootForEntrySkill 在非 external 结构化 CLI 路径中已确定;
2180
2180
  // external 路径或 human-attestation(null)时回退到 snapshotDirReal。
2181
2181
  const pluginRootForSkill = pluginRootForEntrySkill || snapshotDirReal;
2182
- const entrySkillFile = resolve(pluginRootForSkill, 'skills', action.entrySkill, 'SKILL.md');
2182
+ // Prefixed skill-directory fallback (0.5.1 entry-skill-prefix fix,
2183
+ // D-ACA-30 platform_physical_name): some candidate builds name
2184
+ // skill directories `skills/<plugin>-<entrySkill>/` instead of
2185
+ // the unprefixed `skills/<entrySkill>/`. Resolve the unprefixed
2186
+ // layout first; when it misses, retry with the plugin prefix.
2187
+ let entrySkillFile = resolve(pluginRootForSkill, 'skills', action.entrySkill, 'SKILL.md');
2188
+ let entrySkillExists = false;
2183
2189
  try {
2184
2190
  await stat(entrySkillFile);
2191
+ entrySkillExists = true;
2185
2192
  } catch {
2193
+ if (action.plugin) {
2194
+ const prefixed = resolve(
2195
+ pluginRootForSkill,
2196
+ 'skills',
2197
+ `${action.plugin}-${action.entrySkill}`,
2198
+ 'SKILL.md',
2199
+ );
2200
+ try {
2201
+ await stat(prefixed);
2202
+ entrySkillFile = prefixed;
2203
+ entrySkillExists = true;
2204
+ } catch {
2205
+ // both layouts miss; report below
2206
+ }
2207
+ }
2208
+ }
2209
+ if (!entrySkillExists) {
2186
2210
  const skillRel = relative(snapshotDirReal, entrySkillFile) || `skills/${action.entrySkill}/SKILL.md`;
2187
2211
  return createResult({
2188
2212
  actionType,
@@ -3476,8 +3500,11 @@ export function createPluginMarketplaceAdapter(deps = {}) {
3476
3500
  });
3477
3501
  }
3478
3502
 
3479
- // Verify entry skill exists as a regular file in install dir
3480
- const entrySkillPath = resolve(installPath, 'skills', action.entrySkill, 'SKILL.md');
3503
+ // Verify entry skill exists as a regular file in install dir.
3504
+ // Prefixed skill-directory fallback (0.5.1 entry-skill-prefix fix):
3505
+ // mirror the preflight resolution — `skills/<entrySkill>` first,
3506
+ // then `skills/<plugin>-<entrySkill>` when the plugin is known.
3507
+ let entrySkillPath = resolve(installPath, 'skills', action.entrySkill, 'SKILL.md');
3481
3508
  let entrySkillFound = false;
3482
3509
  try {
3483
3510
  const skillStat = await lstat(entrySkillPath);
@@ -3485,7 +3512,24 @@ export function createPluginMarketplaceAdapter(deps = {}) {
3485
3512
  entrySkillFound = true;
3486
3513
  }
3487
3514
  } catch {
3488
- // entry skill not found
3515
+ // entry skill not found in unprefixed layout
3516
+ }
3517
+ if (!entrySkillFound && action.plugin) {
3518
+ const prefixedPath = resolve(
3519
+ installPath,
3520
+ 'skills',
3521
+ `${action.plugin}-${action.entrySkill}`,
3522
+ 'SKILL.md',
3523
+ );
3524
+ try {
3525
+ const skillStat = await lstat(prefixedPath);
3526
+ if (skillStat.isFile() && !skillStat.isSymbolicLink()) {
3527
+ entrySkillPath = prefixedPath;
3528
+ entrySkillFound = true;
3529
+ }
3530
+ } catch {
3531
+ // both layouts miss; report below
3532
+ }
3489
3533
  }
3490
3534
 
3491
3535
  if (!entrySkillFound) {
@@ -58,10 +58,16 @@ function validateAction(action) {
58
58
  githubRepositoryUrl(action.repo, action.githubHost);
59
59
 
60
60
  const validStrategies = ['create-release-branch', 'advance-existing-branch', 'initialize-default-branch'];
61
- const strategy = action.branchStrategy ?? 'create-release-branch';
62
- if (typeof strategy !== 'string' || !validStrategies.includes(strategy)) {
61
+ // Explicit strategy is required (0.5.1 chain hardening): the historical
62
+ // `?? 'create-release-branch'` default silently produced orphan root
63
+ // commits whenever a plan omitted the strategy — the root cause of the
64
+ // synthetic-date root commits in the public repositories. The plan layer
65
+ // now guarantees the value; a missing value here fails closed instead of
66
+ // degrading to the orphan-root channel.
67
+ if (typeof action.branchStrategy !== 'string' || !validStrategies.includes(action.branchStrategy)) {
63
68
  throw new Error(`branchStrategy must be one of: ${validStrategies.join(', ')}`);
64
69
  }
70
+ const strategy = action.branchStrategy;
65
71
  if (strategy === 'advance-existing-branch' || strategy === 'initialize-default-branch') {
66
72
  if (!action.parentCommit || typeof action.parentCommit !== 'string') {
67
73
  throw new Error(`${strategy} requires parentCommit`);
@@ -126,7 +132,8 @@ export function createPushSnapshotAdapter(deps = {}) {
126
132
  { shell: false },
127
133
  );
128
134
  const remoteTip = stdout.trim().split(/\s+/)[0] ?? '';
129
- const strategy = action.branchStrategy ?? 'create-release-branch';
135
+ // Guaranteed by validateAction above (branchStrategy is required).
136
+ const strategy = action.branchStrategy;
130
137
 
131
138
  if (strategy === 'advance-existing-branch') {
132
139
  if (!remoteTip) {
@@ -173,7 +180,8 @@ export function createPushSnapshotAdapter(deps = {}) {
173
180
  });
174
181
  const gitDir = await inspectLocalObjects(action, context, exec);
175
182
  const remoteUrl = githubRepositoryUrl(action.repo, action.githubHost);
176
- const strategy = action.branchStrategy ?? 'create-release-branch';
183
+ // Guaranteed by validateAction above (branchStrategy is required).
184
+ const strategy = action.branchStrategy;
177
185
 
178
186
  if (strategy === 'advance-existing-branch') {
179
187
  await exec(
@@ -709,6 +709,171 @@ async function checkRemotePrerequisites(root, config, offline) {
709
709
  return gaps;
710
710
  }
711
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
+
712
877
  /**
713
878
  * Perform a basic README structural check.
714
879
  *
@@ -967,6 +1132,12 @@ export async function assessProject(options) {
967
1132
  const remoteGaps = await checkRemotePrerequisites(root, config, offline);
968
1133
  allGaps.push(...remoteGaps);
969
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
+
970
1141
  // --- 8. README structure check ---
971
1142
  const readmeGaps = await checkReadmeStructure(root, config);
972
1143
  allGaps.push(...readmeGaps);
@@ -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",
@@ -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 },
@@ -13,6 +13,13 @@
13
13
  * 本桥从 5 级相对路径 import(包外工作树引用)切换为包名 import
14
14
  * ('skill-family-harness-node'),随 npm 依赖发布,不再依赖 Foundation 工作树在旁。
15
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
+ *
16
23
  * 发布形态:包名 import 可直接用于包内 src(node_modules 解析),bundle
17
24
  * (bin/release-skill.bundle.mjs,esbuild 内联)保持自包含。
18
25
  *
package/src/core/plan.mjs CHANGED
@@ -534,8 +534,18 @@ export function validatePlanActionCompleteness(plan, options = {}) {
534
534
  if (!frozen.parentCommit || frozen.parentCommit !== unit.previousPublicBaseline?.commit) {
535
535
  failures.push(`unit "${unitId}" frozenSnapshot.parentCommit does not match previous public baseline commit`);
536
536
  }
537
- } else if (frozen.parentCommit) {
538
- failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
537
+ } else if (branchStrategy === 'create-release-branch') {
538
+ // Chain-integrity gate (0.5.1): create-release-branch creates an
539
+ // orphan root commit (no parent). It is only legitimate for the
540
+ // FIRST release of a repository (previousPublicBaseline.mode=none).
541
+ // A later release using it would start a fresh lineage disconnected
542
+ // from the published history — the historical orphan-root channel.
543
+ if (unit.previousPublicBaseline?.mode === 'bound') {
544
+ failures.push(`unit "${unitId}" create-release-branch is only valid for first releases (previousPublicBaseline.mode=none), but a bound previous public baseline exists`);
545
+ }
546
+ if (frozen.parentCommit) {
547
+ failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
548
+ }
539
549
  }
540
550
  if (
541
551
  branchStrategy === 'advance-existing-branch' &&