release-skill 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) 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 +31 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +13 -21
  10. package/README.zh-CN.md +14 -22
  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 +38147 -23284
  17. package/adapters/claude/bin/rules.json +1 -1
  18. package/adapters/claude/schemas/.render-manifest.json +6 -6
  19. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  20. package/adapters/claude/schemas/release-project.schema.json +291 -0
  21. package/adapters/claude/schemas/release-run.schema.json +59 -6
  22. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  23. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  24. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  25. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  26. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  27. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  28. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  29. package/adapters/codex/bin/error-codes.json +1 -1
  30. package/adapters/codex/bin/kernel-protocol.json +1 -1
  31. package/adapters/codex/bin/registry.json +15 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +38147 -23284
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +6 -6
  35. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  36. package/adapters/codex/schemas/release-project.schema.json +291 -0
  37. package/adapters/codex/schemas/release-run.schema.json +59 -6
  38. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  39. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  40. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  41. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  42. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  43. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  44. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  45. package/adapters/kimi/bin/error-codes.json +1 -1
  46. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  47. package/adapters/kimi/bin/registry.json +15 -1
  48. package/adapters/kimi/bin/release-skill.bundle.mjs +38147 -23284
  49. package/adapters/kimi/bin/rules.json +1 -1
  50. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  51. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  52. package/adapters/kimi/schemas/release-project.schema.json +291 -0
  53. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  54. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  55. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  56. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  57. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  58. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  60. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  61. package/adapters/workbuddy/bin/error-codes.json +1 -1
  62. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  63. package/adapters/workbuddy/bin/registry.json +15 -1
  64. package/adapters/workbuddy/bin/release-skill.bundle.mjs +38147 -23284
  65. package/adapters/workbuddy/bin/rules.json +1 -1
  66. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  67. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  68. package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
  69. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  70. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  71. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  72. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  73. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  74. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  75. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  76. package/bin/error-codes.json +1 -1
  77. package/bin/kernel-protocol.json +1 -1
  78. package/bin/registry.json +15 -1
  79. package/bin/release-skill-cli.mjs +235 -7
  80. package/bin/release-skill.bundle.mjs +38147 -23284
  81. package/bin/rules.json +1 -1
  82. package/package.json +5 -4
  83. package/platform-manifest.json +359 -0
  84. package/references/.render-manifest.json +9 -9
  85. package/references/02-project-config.md +35 -0
  86. package/references/05-evidence-and-errors.md +44 -0
  87. package/references/06-adapter-contract.md +13 -0
  88. package/schemas/.render-manifest.json +6 -6
  89. package/schemas/release-plan.schema.json +401 -2
  90. package/schemas/release-project.schema.json +291 -0
  91. package/schemas/release-run.schema.json +59 -6
  92. package/skills/release-config/SKILL.md +140 -0
  93. package/skills/release-docs/SKILL.md +106 -0
  94. package/skills/release-help/SKILL.md +39 -0
  95. package/skills/release-marketplace/SKILL.md +147 -0
  96. package/skills/release-publish/SKILL.md +25 -6
  97. package/skills/release-verify/SKILL.md +10 -0
  98. package/skills-src/release-config/SKILL.md +140 -0
  99. package/skills-src/release-docs/SKILL.md +106 -0
  100. package/skills-src/release-help/SKILL.md +39 -0
  101. package/skills-src/release-marketplace/SKILL.md +147 -0
  102. package/skills-src/release-publish/SKILL.md +25 -6
  103. package/skills-src/release-verify/SKILL.md +10 -0
  104. package/src/adapters/contract.mjs +5 -0
  105. package/src/adapters/distribute-git.mjs +625 -0
  106. package/src/adapters/plugin-marketplace.mjs +48 -4
  107. package/src/adapters/push-snapshot.mjs +12 -4
  108. package/src/commands/assess.mjs +171 -0
  109. package/src/commands/distribute.mjs +1078 -0
  110. package/src/commands/hooks.mjs +6 -1
  111. package/src/commands/lineage.mjs +616 -0
  112. package/src/commands/prepare.mjs +413 -72
  113. package/src/commands/route.mjs +686 -0
  114. package/src/commands/ship.mjs +45 -1
  115. package/src/commands/verify.mjs +176 -0
  116. package/src/core/baseline-advance.mjs +185 -0
  117. package/src/core/baseline.mjs +11 -1
  118. package/src/core/checkpoints.mjs +25 -0
  119. package/src/core/foundation-inflight.mjs +7 -0
  120. package/src/core/plan.mjs +12 -2
  121. package/src/core/postpublish.mjs +315 -0
@@ -60,6 +60,7 @@ async function defaultDependencies() {
60
60
  publishModule,
61
61
  reconcileModule,
62
62
  verifyModule,
63
+ distributeModule,
63
64
  transportModule,
64
65
  metadataModule,
65
66
  ] = await Promise.all([
@@ -69,6 +70,7 @@ async function defaultDependencies() {
69
70
  import('./publish.mjs'),
70
71
  import('./reconcile.mjs'),
71
72
  import('./verify.mjs'),
73
+ import('./distribute.mjs'),
72
74
  import('../core/git-transport.mjs'),
73
75
  import('../core/release-metadata.mjs'),
74
76
  ]);
@@ -79,6 +81,7 @@ async function defaultDependencies() {
79
81
  publishRelease: publishModule.publishRelease,
80
82
  reconcileRelease: reconcileModule.reconcileRelease,
81
83
  verifyRelease: verifyModule.verifyRelease,
84
+ distributeRelease: distributeModule.distributeRelease,
82
85
  preflightGitTransports: transportModule.preflightGitTransports,
83
86
  updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
84
87
  };
@@ -102,6 +105,7 @@ function publicState(state) {
102
105
  ...(state.approvalSummary ? { approvalSummary: state.approvalSummary } : {}),
103
106
  ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
104
107
  ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
108
+ ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
105
109
  ...(state.requirements ? { requirements: state.requirements } : {}),
106
110
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
107
111
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
@@ -301,10 +305,49 @@ export async function advanceShip(options = {}, injected = {}) {
301
305
  }
302
306
 
303
307
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
308
+ // Step 1: Check if postPublish requires distribution
309
+ const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
310
+ const needsDistribution = plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0;
311
+
312
+ if (needsDistribution && deps.distributeRelease) {
313
+ state.status = 'DISTRIBUTING';
314
+ await writeJsonAtomic(statePath, state);
315
+
316
+ try {
317
+ const distributed = await deps.distributeRelease({
318
+ sourceRunPath: state.sourceRunPath,
319
+ approvalPath: state.approvalPath,
320
+ adapterRegistry: options.adapterRegistry,
321
+ root,
322
+ dryRun: false,
323
+ planPath: state.planPath,
324
+ });
325
+
326
+ state = {
327
+ ...state,
328
+ status: distributed.status,
329
+ distributeRunPath: distributed.distributeRunPath,
330
+ updatedAt: new Date().toISOString(),
331
+ };
332
+ await writeJsonAtomic(statePath, state);
333
+
334
+ if (!distributed.checkpoints || distributed.checkpoints.some((cp) => cp.status === 'failed')) {
335
+ state.status = 'PARTIAL';
336
+ await writeJsonAtomic(statePath, state);
337
+ return publicState(state);
338
+ }
339
+ } catch (error) {
340
+ state.status = 'PARTIAL';
341
+ await writeJsonAtomic(statePath, state);
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ // Step 2: Verify (this will check for distributed run if distribution was required)
304
347
  try {
305
348
  const verified = await deps.verifyRelease({
306
349
  planPath: state.planPath,
307
- sourceRunPath: state.sourceRunPath,
350
+ sourceRunPath: needsDistribution ? state.distributeRunPath : state.sourceRunPath,
308
351
  adapterRegistry: options.adapterRegistry,
309
352
  root,
310
353
  verificationGatesAuthorized: state.verificationGatesAuthorized === true,
@@ -315,6 +358,7 @@ export async function advanceShip(options = {}, injected = {}) {
315
358
  verifyRunPath: verified.runPath,
316
359
  requirements: undefined,
317
360
  manualFollowUps: verified.manualFollowUps ?? undefined,
361
+ baselineAdvance: verified.baselineAdvance ?? undefined,
318
362
  updatedAt: new Date().toISOString(),
319
363
  };
320
364
  await writeJsonAtomic(statePath, state);
@@ -46,6 +46,13 @@ import {
46
46
  CONSUMER_VERIFICATION_DEFERRED,
47
47
  } from '../core/errors.mjs';
48
48
  import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
49
+ import {
50
+ deriveBaselineAdvances,
51
+ applyBaselineAdvances,
52
+ isWorktreeFileClean,
53
+ commitBaselineAdvance,
54
+ } from '../core/baseline-advance.mjs';
55
+ import { loadProjectConfig } from '../core/config.mjs';
49
56
  import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
50
57
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
51
58
  import {
@@ -725,6 +732,95 @@ const defaultNpmExecutor = {
725
732
  },
726
733
  };
727
734
 
735
+ // ---------------------------------------------------------------------------
736
+ // Distribute run discovery
737
+ // ---------------------------------------------------------------------------
738
+
739
+ /**
740
+ * Discover sibling distribute runs from the same planDigest within .release-skill/runs.
741
+ * Only accept verified, non-symbolic-link directories matching distribute-* prefix.
742
+ */
743
+ async function discoverDistributeRuns({ planPath, plan }) {
744
+ const planDir = dirname(planPath);
745
+ const releaseDir = basename(planDir) === 'plans' ? dirname(planDir) : planDir;
746
+ const runsDir = resolve(releaseDir, 'runs');
747
+
748
+ try {
749
+ const runsDirStat = await lstat(runsDir);
750
+ if (runsDirStat.isSymbolicLink()) {
751
+ throw new ReleaseError(
752
+ GATE_FAILED,
753
+ 'runs directory is a symbolic link; authority identity compromised',
754
+ { runsDir },
755
+ );
756
+ }
757
+ if (!runsDirStat.isDirectory()) {
758
+ throw new ReleaseError(
759
+ GATE_FAILED,
760
+ 'runs path is not a directory',
761
+ { runsDir },
762
+ );
763
+ }
764
+
765
+ const runsDirReal = realpathSync(runsDir);
766
+ const authorityDirReal = realpathSync(releaseDir);
767
+ if (!runsDirReal.startsWith(authorityDirReal + '/') && runsDirReal !== authorityDirReal) {
768
+ throw new ReleaseError(
769
+ GATE_FAILED,
770
+ 'runs directory is not a real child of the plan authority directory',
771
+ { runsDir, runsDirReal, authorityDirReal },
772
+ );
773
+ }
774
+ } catch (err) {
775
+ if (err instanceof ReleaseError) throw err;
776
+ // runs directory doesn't exist — this is okay for optional distribution
777
+ return null;
778
+ }
779
+
780
+ const candidates = [];
781
+ const entries = await readdir(runsDir, { withFileTypes: true });
782
+ for (const entry of entries) {
783
+ if (!entry.name.startsWith('distribute-')) continue;
784
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
785
+ throw new ReleaseError(
786
+ GATE_FAILED,
787
+ 'distribute-* candidate is a symbolic link or not a directory; authority identity compromised',
788
+ { entry: entry.name, runsDir },
789
+ );
790
+ }
791
+ const candidateDir = resolve(runsDir, entry.name);
792
+ const candidateStat = await lstat(candidateDir).catch(() => null);
793
+ if (!candidateStat || candidateStat.isSymbolicLink()) {
794
+ throw new ReleaseError(
795
+ GATE_FAILED,
796
+ 'distribute-* candidate is a symbolic link; authority identity compromised',
797
+ { candidateDir, runsDir },
798
+ );
799
+ }
800
+ const candidateReal = realpathSync(candidateDir);
801
+ if (!candidateReal.startsWith(runsDir + '/') && candidateReal !== runsDir) {
802
+ throw new ReleaseError(
803
+ GATE_FAILED,
804
+ 'distribute-* candidate real path is not contained in authority runs directory',
805
+ { candidateDir, candidateReal, runsDir },
806
+ );
807
+ }
808
+ const candidatePath = resolve(candidateDir, 'release-run.json');
809
+ try {
810
+ const candidate = await loadRun(candidatePath, { requireDigest: true });
811
+ if (candidate.command !== 'distribute') continue;
812
+ if (!candidate.planDigest) continue;
813
+ if (candidate.planDigest !== plan.digest) continue;
814
+ candidates.push(candidate);
815
+ } catch {
816
+ // Skip invalid candidates silently
817
+ continue;
818
+ }
819
+ }
820
+
821
+ return candidates;
822
+ }
823
+
728
824
  // ---------------------------------------------------------------------------
729
825
  // Public API
730
826
  // ---------------------------------------------------------------------------
@@ -758,6 +854,8 @@ export async function verifyRelease(options) {
758
854
  verificationGatesAuthorized: _verificationGatesAuthorized,
759
855
  gateEnv,
760
856
  previousVerifyRun,
857
+ execFn,
858
+ configPath: configPathOpt,
761
859
  } = options ?? {};
762
860
 
763
861
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -866,6 +964,32 @@ export async function verifyRelease(options) {
866
964
  );
867
965
  }
868
966
 
967
+ // =======================================================================
968
+ // Step 2b: Check for postPublish distribution requirement
969
+ // =======================================================================
970
+ if (plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0) {
971
+ await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
972
+
973
+ const distributeCandidates = await discoverDistributeRuns({ planPath, plan });
974
+ const distRunPath = (distributeCandidates ?? []).find((c) => c.status === 'DISTRIBUTED')?.runPath || null;
975
+
976
+ await evidence.append({
977
+ phase: 'verify',
978
+ step: 'distribute-run-discovery',
979
+ status: 'checked',
980
+ foundCandidate: !!distRunPath,
981
+ distributeRunPath: distRunPath,
982
+ });
983
+
984
+ if (!distRunPath) {
985
+ throw new ReleaseError(
986
+ GATE_FAILED,
987
+ `distribution required by plan.postPublish but no DISTRIBUTED run found; run release-skill distribute --plan ${planPath} --root ${root}`,
988
+ { requiredDistribution: true, evidenceEvent: 'distribute-run-missing' },
989
+ );
990
+ }
991
+ }
992
+
869
993
  if (plan.production) {
870
994
  if (!sourceRun.approvalPath || !sourceRun.approvalDigest) {
871
995
  throw new ReleaseError(
@@ -1678,6 +1802,57 @@ export async function verifyRelease(options) {
1678
1802
  };
1679
1803
  const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
1680
1804
 
1805
+ // =======================================================================
1806
+ // Baseline advance: move per-unit previousPublicBaseline to the commit
1807
+ // that was just verified. Local bookkeeping only — no remote writes.
1808
+ // Best-effort: a failure here must never demote VERIFIED, and the config
1809
+ // is only auto-committed when the file was clean before the write, so a
1810
+ // dirty worktree leaves the decision to the human.
1811
+ // =======================================================================
1812
+ let baselineAdvance = null;
1813
+ const baselineAdvances = deriveBaselineAdvances(plan);
1814
+ if (baselineAdvances.length > 0) {
1815
+ try {
1816
+ const configAbs = configPathOpt
1817
+ ? (isAbsolute(configPathOpt) ? configPathOpt : join(root, configPathOpt))
1818
+ : join(root, '.release-skill/project.yaml');
1819
+ const cleanBefore = await isWorktreeFileClean({ root, filePath: configAbs, execFn });
1820
+ const applyResult = await applyBaselineAdvances({
1821
+ configPath: configAbs,
1822
+ advances: baselineAdvances,
1823
+ validateFn: async () => { await loadProjectConfig({ root, configPath: configAbs }); },
1824
+ });
1825
+ baselineAdvance = { ...applyResult, committed: false };
1826
+ if (applyResult.changed && cleanBefore) {
1827
+ const versionLabel = (plan.units ?? [])
1828
+ .map((u) => `${u.id} v${u.targetVersion}`)
1829
+ .join(', ');
1830
+ await commitBaselineAdvance({
1831
+ root,
1832
+ filePath: configAbs,
1833
+ message: `chore: advance previousPublicBaseline to published ${versionLabel}`,
1834
+ execFn,
1835
+ });
1836
+ baselineAdvance.committed = true;
1837
+ }
1838
+ await evidence.append({
1839
+ phase: 'verify',
1840
+ step: 'baseline-advance',
1841
+ status: applyResult.changed ? 'advanced' : 'already-current',
1842
+ updatedUnits: applyResult.updatedUnits,
1843
+ committed: baselineAdvance.committed,
1844
+ });
1845
+ } catch (err) {
1846
+ baselineAdvance = { failed: true, error: err.message };
1847
+ await evidence.append({
1848
+ phase: 'verify',
1849
+ step: 'baseline-advance',
1850
+ status: 'failed',
1851
+ error: { code: err.code, message: err.message },
1852
+ });
1853
+ }
1854
+ }
1855
+
1681
1856
  await evidence.finish({
1682
1857
  status: VERIFIED,
1683
1858
  planPath,
@@ -1697,6 +1872,7 @@ export async function verifyRelease(options) {
1697
1872
  smokeTest,
1698
1873
  gateResults: consumerGateResults,
1699
1874
  ...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
1875
+ ...(baselineAdvance ? { baselineAdvance } : {}),
1700
1876
  };
1701
1877
  } catch (err) {
1702
1878
  await evidence.append({
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Baseline advance: after a release reaches VERIFIED, the project config's
3
+ * per-unit `previousPublicBaseline` must be advanced from the previous
4
+ * public commit to the commit that was just published. Doing this by hand
5
+ * is the most error-prone step in the release loop, so it is derived from
6
+ * the frozen plan (never recomputed from workspace state) and written back
7
+ * into `.release-skill/project.yaml` with comment preservation.
8
+ *
9
+ * Fail-safe by construction: any unit whose plan data is missing or
10
+ * malformed is skipped rather than guessed.
11
+ */
12
+ import { readFile, writeFile } from 'node:fs/promises';
13
+ import { execFile as execFileCb } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+ import YAML from 'yaml';
16
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
17
+
18
+ const execFileAsync = promisify(execFileCb);
19
+
20
+ function defaultExec(command, args, options = {}) {
21
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
22
+ }
23
+
24
+ const SHA_RE = /^[a-f0-9]{40}$/;
25
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
26
+
27
+ /**
28
+ * Derive the baseline advances that a VERIFIED plan implies.
29
+ *
30
+ * For each unit with a `mode: bound` previousPublicBaseline, the published
31
+ * commit/tree/manifestDigest come from the unit's frozen push-snapshot
32
+ * action — the same values that were pushed to the public repo.
33
+ *
34
+ * @param {{ units?: Array<Record<string, unknown>>, externalActions?: Array<Record<string, unknown>> }} plan
35
+ * The frozen release plan.
36
+ * @returns {Array<{ unitId: string, repo: string, previousCommit: string, commit: string, tree: string, manifestDigest: string }>}
37
+ * One advance per unit that is behind its published commit. Units already
38
+ * at the published commit (idempotent re-runs) produce no advance.
39
+ */
40
+ export function deriveBaselineAdvances(plan) {
41
+ const advances = [];
42
+ for (const unit of plan?.units ?? []) {
43
+ const baseline = unit?.previousPublicBaseline;
44
+ if (!baseline || baseline.mode !== 'bound') continue;
45
+
46
+ const action = (plan?.externalActions ?? []).find((a) => (
47
+ a?.type === 'push-snapshot'
48
+ && a?.parameters?.publicRepo === unit.publicRepo
49
+ ));
50
+ const { commit, tree, manifestDigest } = action?.parameters ?? {};
51
+
52
+ if (!SHA_RE.test(commit ?? '') || !SHA_RE.test(tree ?? '') || !DIGEST_RE.test(manifestDigest ?? '')) {
53
+ continue;
54
+ }
55
+ if (baseline.commit === commit) continue;
56
+
57
+ advances.push({
58
+ unitId: unit.id,
59
+ repo: baseline.repo,
60
+ previousCommit: baseline.commit,
61
+ commit,
62
+ tree,
63
+ manifestDigest,
64
+ });
65
+ }
66
+ return advances;
67
+ }
68
+
69
+ /**
70
+ * Apply derived advances to the project config file, preserving comments and
71
+ * unrelated content by editing the YAML AST in place. When a `validateFn` is
72
+ * provided, the rewritten file is re-validated and the original content is
73
+ * restored if validation fails (the advance must never corrupt the config).
74
+ *
75
+ * @param {{ configPath: string, advances: Array<Record<string, unknown>>, validateFn?: (configPath: string) => Promise<void> }} opts
76
+ * @returns {Promise<{ changed: boolean, updatedUnits: string[] }>}
77
+ */
78
+ export async function applyBaselineAdvances({ configPath, advances, validateFn }) {
79
+ if (!advances || advances.length === 0) {
80
+ return { changed: false, updatedUnits: [] };
81
+ }
82
+
83
+ let content;
84
+ try {
85
+ content = await readFile(configPath, 'utf8');
86
+ } catch (err) {
87
+ throw new ReleaseError(
88
+ GATE_FAILED,
89
+ `baseline advance cannot read project config: ${err.message}`,
90
+ { configPath, cause: err.code },
91
+ );
92
+ }
93
+
94
+ const doc = YAML.parseDocument(content);
95
+ const releaseUnits = doc.get('releaseUnits', true);
96
+ if (!YAML.isSeq(releaseUnits)) {
97
+ throw new ReleaseError(
98
+ GATE_FAILED,
99
+ 'baseline advance requires a releaseUnits sequence in the project config',
100
+ { configPath },
101
+ );
102
+ }
103
+
104
+ const updatedUnits = [];
105
+ for (const advance of advances) {
106
+ const unitNode = releaseUnits.items.find((item) => (
107
+ YAML.isMap(item) && item.get('id') === advance.unitId
108
+ ));
109
+ if (!unitNode) continue;
110
+
111
+ let baselineNode = unitNode.get('previousPublicBaseline', true);
112
+ if (!YAML.isMap(baselineNode)) {
113
+ baselineNode = new YAML.YAMLMap();
114
+ unitNode.set('previousPublicBaseline', baselineNode);
115
+ }
116
+
117
+ let changed = false;
118
+ const setField = (key, value) => {
119
+ if (baselineNode.get(key) !== value) {
120
+ baselineNode.set(key, value);
121
+ changed = true;
122
+ }
123
+ };
124
+ setField('commit', advance.commit);
125
+ setField('tree', advance.tree);
126
+ setField('manifestDigest', advance.manifestDigest);
127
+
128
+ if (changed) updatedUnits.push(advance.unitId);
129
+ }
130
+
131
+ if (updatedUnits.length === 0) {
132
+ return { changed: false, updatedUnits: [] };
133
+ }
134
+
135
+ const next = doc.toString();
136
+ await writeFile(configPath, next, 'utf8');
137
+
138
+ if (typeof validateFn === 'function') {
139
+ try {
140
+ await validateFn(configPath);
141
+ } catch (err) {
142
+ // Restore the original content so a bad advance never corrupts config.
143
+ await writeFile(configPath, content, 'utf8');
144
+ throw new ReleaseError(
145
+ GATE_FAILED,
146
+ `baseline advance reverted: rewritten config failed validation: ${err.message}`,
147
+ { configPath, cause: err.message },
148
+ );
149
+ }
150
+ }
151
+
152
+ return { changed: true, updatedUnits };
153
+ }
154
+
155
+ /**
156
+ * Report whether a single file is clean in the git worktree (no staged or
157
+ * unstaged changes). Fails safe to `false` on any git error so that a
158
+ * non-repository or a broken git never authorizes an automatic commit.
159
+ *
160
+ * @param {{ root: string, filePath: string, execFn?: Function }} opts
161
+ * @returns {Promise<boolean>}
162
+ */
163
+ export async function isWorktreeFileClean({ root, filePath, execFn }) {
164
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
165
+ try {
166
+ const { stdout } = await exec('git', ['status', '--porcelain', '--', filePath], { cwd: root });
167
+ return stdout.trim() === '';
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Stage and commit only the config file. The caller is responsible for
175
+ * checking `isWorktreeFileClean` *before* writing so that this commit never
176
+ * sweeps in unrelated in-flight work.
177
+ *
178
+ * @param {{ root: string, filePath: string, message: string, execFn?: Function }} opts
179
+ * @returns {Promise<void>}
180
+ */
181
+ export async function commitBaselineAdvance({ root, filePath, message, execFn }) {
182
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
183
+ await exec('git', ['add', '--', filePath], { cwd: root });
184
+ await exec('git', ['commit', '-m', message, '--', filePath], { cwd: root });
185
+ }
@@ -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 },
@@ -189,3 +189,28 @@ export function isRemoteWriteAction(actionType) {
189
189
  export function isMarketplaceAction(actionType) {
190
190
  return MARKETPLACE_ACTION_TYPES.has(actionType);
191
191
  }
192
+
193
+ /**
194
+ * Post-publish distribution (distribute saga) checkpoint action types.
195
+ *
196
+ * These checkpoints are recorded in distribute run records; they never
197
+ * appear in plan externalActions and intentionally stay OUT of
198
+ * CHECKPOINT_ORDER / TIER_TABLE: the distribute saga schedules targets
199
+ * strictly sequentially in dependsOn order (shared payload + sha backfill),
200
+ * never through the publish/reconcile tier scheduler. If a distribute type
201
+ * ever leaked into a publish plan, groupActionsByTier would fail closed on
202
+ * it as an unknown type — exactly the desired behaviour.
203
+ */
204
+ export const DISTRIBUTE_ACTION_TYPES = new Set([
205
+ 'distribute-probe',
206
+ 'distribute-mirror',
207
+ ]);
208
+
209
+ /**
210
+ * 判断动作类型是否为发布后分发(distribute)动作。
211
+ * @param {string} actionType - 运行检查点中的动作类型
212
+ * @returns {boolean}
213
+ */
214
+ export function isDistributeAction(actionType) {
215
+ return DISTRIBUTE_ACTION_TYPES.has(actionType);
216
+ }
@@ -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' &&