release-skill 0.8.0 → 0.9.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 (144) 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 +65 -7
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +51 -31
  10. package/README.zh-CN.md +33 -20
  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/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/prebuild-manifest.json +8 -4
  16. package/adapters/claude/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  17. package/adapters/claude/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  18. package/adapters/claude/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  19. package/adapters/claude/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  20. package/adapters/claude/bin/registry.json +22 -1
  21. package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
  22. package/adapters/claude/bin/release-skill.bundle.mjs +7152 -4231
  23. package/adapters/claude/bin/rules.json +1 -1
  24. package/adapters/claude/schemas/release-plan.schema.json +43 -5
  25. package/adapters/claude/schemas/release-run.schema.json +35 -1
  26. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  27. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  28. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +55 -0
  29. package/adapters/claude/src/schemas/host-verification-result.schema.json +2 -2
  30. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +374 -0
  31. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +799 -0
  32. package/adapters/claude/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/error-codes.json +1 -1
  35. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  36. package/adapters/codex/bin/prebuild-manifest.json +8 -4
  37. package/adapters/codex/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  38. package/adapters/codex/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  39. package/adapters/codex/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  40. package/adapters/codex/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  41. package/adapters/codex/bin/registry.json +22 -1
  42. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  43. package/adapters/codex/bin/release-skill.bundle.mjs +7152 -4231
  44. package/adapters/codex/bin/rules.json +1 -1
  45. package/adapters/codex/schemas/release-plan.schema.json +43 -5
  46. package/adapters/codex/schemas/release-run.schema.json +35 -1
  47. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  48. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  49. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +55 -0
  50. package/adapters/codex/src/schemas/host-verification-result.schema.json +2 -2
  51. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +374 -0
  52. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +799 -0
  53. package/adapters/codex/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  54. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  55. package/adapters/kimi/bin/error-codes.json +1 -1
  56. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  57. package/adapters/kimi/bin/prebuild-manifest.json +8 -4
  58. package/adapters/kimi/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  59. package/adapters/kimi/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  60. package/adapters/kimi/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  61. package/adapters/kimi/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  62. package/adapters/kimi/bin/registry.json +22 -1
  63. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  64. package/adapters/kimi/bin/release-skill.bundle.mjs +7152 -4231
  65. package/adapters/kimi/bin/rules.json +1 -1
  66. package/adapters/kimi/schemas/release-plan.schema.json +43 -5
  67. package/adapters/kimi/schemas/release-run.schema.json +35 -1
  68. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  69. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  70. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +55 -0
  71. package/adapters/kimi/src/schemas/host-verification-result.schema.json +2 -2
  72. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +374 -0
  73. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +799 -0
  74. package/adapters/kimi/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  75. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  76. package/adapters/workbuddy/bin/error-codes.json +1 -1
  77. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  78. package/adapters/workbuddy/bin/prebuild-manifest.json +8 -4
  79. package/adapters/workbuddy/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  80. package/adapters/workbuddy/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  81. package/adapters/workbuddy/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  82. package/adapters/workbuddy/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  83. package/adapters/workbuddy/bin/registry.json +22 -1
  84. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  85. package/adapters/workbuddy/bin/release-skill.bundle.mjs +7152 -4231
  86. package/adapters/workbuddy/bin/rules.json +1 -1
  87. package/adapters/workbuddy/schemas/release-plan.schema.json +43 -5
  88. package/adapters/workbuddy/schemas/release-run.schema.json +35 -1
  89. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  90. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  91. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +55 -0
  92. package/adapters/workbuddy/src/schemas/host-verification-result.schema.json +2 -2
  93. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +374 -0
  94. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +799 -0
  95. package/adapters/workbuddy/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  96. package/bin/error-codes.json +1 -1
  97. package/bin/foundation-resource-binding.json +1 -1
  98. package/bin/prebuild-manifest.json +8 -4
  99. package/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  100. package/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  101. package/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  102. package/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  103. package/bin/registry.json +22 -1
  104. package/bin/release-skill-cli.mjs +126 -1
  105. package/bin/release-skill-local-finish.mjs +4 -0
  106. package/bin/release-skill.bundle.mjs +7152 -4231
  107. package/bin/rules.json +1 -1
  108. package/package.json +4 -4
  109. package/platform-manifest.json +30 -4
  110. package/schemas/release-plan.schema.json +43 -5
  111. package/schemas/release-run.schema.json +35 -1
  112. package/skills/release-finish/SKILL.md +65 -0
  113. package/skills/release-verify/SKILL.md +6 -1
  114. package/skills-src/release-finish/SKILL.md +65 -0
  115. package/skills-src/release-verify/SKILL.md +6 -1
  116. package/src/commands/approve.mjs +14 -2
  117. package/src/commands/distribute.mjs +562 -321
  118. package/src/commands/post-release-local.mjs +611 -0
  119. package/src/commands/postverify.mjs +262 -155
  120. package/src/commands/prepare.mjs +83 -57
  121. package/src/commands/publish.mjs +11 -10
  122. package/src/commands/reconcile.mjs +11 -10
  123. package/src/commands/setup.mjs +26 -13
  124. package/src/commands/ship.mjs +45 -12
  125. package/src/commands/verify.mjs +81 -18
  126. package/src/core/adoption-assessment.mjs +0 -3
  127. package/src/core/approval.mjs +11 -8
  128. package/src/core/foundation-plugin-verification.mjs +247 -0
  129. package/src/core/hooks.mjs +0 -16
  130. package/src/core/plan.mjs +19 -11
  131. package/src/core/postpublish-approval.mjs +12 -2
  132. package/src/core/postpublish-bundle.mjs +51 -13
  133. package/src/core/postpublish.mjs +146 -6
  134. package/src/core/preset-executor.mjs +53 -0
  135. package/src/core/proposal-inbox.mjs +91 -27
  136. package/src/core/recovery.mjs +27 -12
  137. package/src/core/skill-resource-closure.mjs +8 -4
  138. package/src/producers/build-adapters.mjs +10 -1
  139. package/src/producers/foundation-resource-projection.mjs +3 -3
  140. package/src/schemas/filesystem-tree-observation.schema.json +55 -0
  141. package/src/schemas/host-verification-result.schema.json +2 -2
  142. package/src/schemas/plugin-verification-request.schema.json +374 -0
  143. package/src/schemas/plugin-verification-result.schema.json +799 -0
  144. package/src/schemas/watchdog-termination-envelope.schema.json +16 -0
@@ -23,6 +23,7 @@ import { resolveContained } from 'skill-family-harness-node';
23
23
  const execFile = promisify(execFileCb);
24
24
 
25
25
  import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from '../core/plan.mjs';
26
+ import { normalizePostPublishView, postPublishActionId } from '../core/postpublish.mjs';
26
27
  import { createEvidenceWriter } from '../core/evidence.mjs';
27
28
  import { readRunRecovery } from '../core/recovery.mjs';
28
29
  import {
@@ -62,6 +63,10 @@ import {
62
63
  import { loadProjectConfig } from '../core/config.mjs';
63
64
  import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
64
65
  import { clearFrozenMarker } from '../core/frozen-marker.mjs';
66
+ import {
67
+ isFoundationPluginVerificationEligible,
68
+ verifyFrozenPluginWithFoundation,
69
+ } from '../core/foundation-plugin-verification.mjs';
65
70
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
66
71
  import {
67
72
  normalizeRegistry,
@@ -884,7 +889,7 @@ async function discoverDistributeRuns({ planPath, plan }) {
884
889
  * governance rule that NEEDS_INPUT/BLOCKED cannot silently become VERIFIED.
885
890
  *
886
891
  * @param {Object} run - A distribute run record (checkpoints + status).
887
- * @param {Object} plan - The frozen release plan (postPublish.hooks source).
892
+ * @param {Object} plan - The frozen release plan (normalized postPublish declaration source).
888
893
  * @returns {{ pass: boolean, warned: boolean, exemptions: Object[] }}
889
894
  */
890
895
  export function evaluateDistributeGateRun(run, plan) {
@@ -892,12 +897,19 @@ export function evaluateDistributeGateRun(run, plan) {
892
897
  if (run.status === 'DISTRIBUTED') return { pass: true, warned: false, exemptions: [] };
893
898
  if (run.status !== 'PARTIAL') return { pass: false, warned: false, exemptions: [] };
894
899
 
895
- const hooksById = new Map(((plan?.postPublish?.hooks ?? [])).map((hook) => [hook.id, hook]));
900
+ // §4.3 unified normalization: hooks resolve across all declarations and
901
+ // are keyed by the SAME action-id derivation the distribute run used, so
902
+ // v3 namespaced checkpoint ids (unitId/hookId) map back to their hook.
903
+ const hooksByActionId = new Map((plan ? normalizePostPublishView(plan) : [])
904
+ .flatMap((declaration) => (declaration.hooks ?? []).map((hook) => [
905
+ postPublishActionId({ planVersion: plan?.planVersion, unitId: declaration.unitId, localId: hook.id }),
906
+ hook,
907
+ ])));
896
908
  const exemptions = [];
897
909
  for (const checkpoint of run.checkpoints ?? []) {
898
910
  if (checkpoint.status === 'succeeded' || checkpoint.status === 'skipped') continue;
899
911
  const hook = checkpoint.actionType === 'postpublish-hook'
900
- ? hooksById.get(checkpoint.actionId)
912
+ ? hooksByActionId.get(checkpoint.actionId)
901
913
  : undefined;
902
914
  if (hook && checkpoint.status === 'failed' && hook.blocksVerified === false) {
903
915
  exemptions.push({ actionId: checkpoint.actionId, status: checkpoint.status });
@@ -925,7 +937,7 @@ export function evaluateDistributeGateRun(run, plan) {
925
937
  * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
926
938
  * @param {Object} [options.previousVerifyRun] - 上一次验证成功的 verify run 记录,用于安装契约摘要免验。
927
939
  *
928
- * @returns {Promise<{ planPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
940
+ * @returns {Promise<{ planPath: string, runPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
929
941
  *
930
942
  * @throws {ReleaseError} GATE_FAILED on safety gate failures.
931
943
  * @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED if any verification fails.
@@ -944,6 +956,7 @@ export async function verifyRelease(options) {
944
956
  previousVerifyRun,
945
957
  execFn,
946
958
  configPath: configPathOpt,
959
+ runPluginVerificationFn,
947
960
  } = options ?? {};
948
961
 
949
962
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -1063,10 +1076,12 @@ export async function verifyRelease(options) {
1063
1076
  // PARTIAL distribute run passes only through the blocksVerified:false
1064
1077
  // exemption path (evaluateDistributeGateRun) — warned, never silent.
1065
1078
  // =======================================================================
1066
- const declaredPostPublishTargets = plan.postPublish?.targets ?? [];
1067
- const declaredPostPublishHooks = plan.postPublish?.hooks ?? [];
1068
- if (plan.postPublish
1069
- && (declaredPostPublishTargets.length > 0 || declaredPostPublishHooks.length > 0)) {
1079
+ // §4.3 unified normalization: v3 empty arrays mean no distribution
1080
+ // requirement; legacy absent postPublish resolves to the same empty view.
1081
+ const postPublishDeclarations = normalizePostPublishView(plan);
1082
+ const requiresDistribution = postPublishDeclarations.some((declaration) =>
1083
+ (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
1084
+ if (requiresDistribution) {
1070
1085
  await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
1071
1086
 
1072
1087
  const distributeCandidates = await discoverDistributeRuns({ planPath, plan }) ?? [];
@@ -1351,10 +1366,15 @@ export async function verifyRelease(options) {
1351
1366
  }
1352
1367
  }
1353
1368
 
1354
- // When the plan declares humanConsumersStrategy: 'manualFollowUps',
1355
- // Kimi/CodeBuddy installations are non-blocking post-release manual tasks.
1356
- // Skip attestation collection and blocking for these platforms.
1357
- const isHumanConsumerFollowUp = plan.humanConsumersStrategy === 'manualFollowUps';
1369
+ // Kimi/CodeBuddy remain non-blocking post-release manual tasks. New plans
1370
+ // additionally require a Foundation observation of the complete frozen
1371
+ // payload before the manual marketplace/invocation follow-up is emitted.
1372
+ const isHumanConsumerFollowUp = [
1373
+ 'manualFollowUps',
1374
+ 'foundationPayloadThenManualFollowUps',
1375
+ ].includes(plan.humanConsumersStrategy);
1376
+ const requiresFoundationPayloadObservation = plan.humanConsumersStrategy
1377
+ === 'foundationPayloadThenManualFollowUps';
1358
1378
 
1359
1379
  const missingManualAttestations = isHumanConsumerFollowUp
1360
1380
  ? []
@@ -1382,6 +1402,7 @@ export async function verifyRelease(options) {
1382
1402
 
1383
1403
  // Collect non-blocking manual follow-up tasks for human consumer platforms
1384
1404
  const manualFollowUps = [];
1405
+ const foundationPluginVerificationReceipts = [];
1385
1406
 
1386
1407
  // Every action is identity-bound and adapters receive read-only or
1387
1408
  // per-action isolated consumer paths. Run independent checks concurrently;
@@ -1414,13 +1435,40 @@ export async function verifyRelease(options) {
1414
1435
  }
1415
1436
 
1416
1437
  if (isMarketplaceAction(action.type)) {
1417
- // When humanConsumersStrategy is 'manualFollowUps', Kimi/CodeBuddy
1418
- // installations are non-blocking manual follow-up tasks. Skip adapter
1419
- // processing and collect them as manualFollowUps.
1438
+ // Both human-consumer strategies keep marketplace installation and
1439
+ // invocation as non-blocking manual follow-ups. The new strategy first
1440
+ // requires Foundation to observe the identity-bound frozen payload.
1420
1441
  const isHumanConsumerPlatform = action.type === 'kimi-marketplace-install'
1421
1442
  || action.type === 'codebuddy-marketplace-install';
1422
1443
  if (isHumanConsumerFollowUp && isHumanConsumerPlatform) {
1423
1444
  const platform = action.type === 'kimi-marketplace-install' ? 'kimi' : 'codebuddy';
1445
+ const observesFoundationPayload = requiresFoundationPayloadObservation
1446
+ && isFoundationPluginVerificationEligible({ action, units: plan.units });
1447
+ if (observesFoundationPayload) {
1448
+ const actionRunDir = join(runDir, 'foundation-plugin-verification', action.id);
1449
+ await mkdir(join(actionRunDir, 'install'), { recursive: true, mode: 0o700 });
1450
+ await mkdir(join(actionRunDir, 'temporary'), { recursive: true, mode: 0o700 });
1451
+ await mkdir(join(actionRunDir, 'evidence'), { recursive: true, mode: 0o700 });
1452
+ const receipt = await verifyFrozenPluginWithFoundation({
1453
+ plan,
1454
+ action,
1455
+ root,
1456
+ runDir: actionRunDir,
1457
+ ...(runPluginVerificationFn ? { runPluginVerificationFn } : {}),
1458
+ clock: clockFn,
1459
+ });
1460
+ foundationPluginVerificationReceipts.push(receipt);
1461
+ await evidence.append({
1462
+ phase: 'verify-marketplace',
1463
+ actionId: action.id,
1464
+ actionType: action.type,
1465
+ status: 'FOUNDATION_PAYLOAD_OBSERVED',
1466
+ requestDigest: receipt.requestDigest,
1467
+ hostId: receipt.hostId,
1468
+ payloadMatches: receipt.payloadMatches,
1469
+ observationDigest: receipt.observationDigest,
1470
+ });
1471
+ }
1424
1472
  manualFollowUps.push({
1425
1473
  actionId: action.id,
1426
1474
  platform,
@@ -1428,20 +1476,26 @@ export async function verifyRelease(options) {
1428
1476
  version: action.parameters?.version,
1429
1477
  unitId: action.unitId,
1430
1478
  verifiedBySystem: false,
1431
- reason: 'human consumer platform — installation is a manual post-release task; not verified by system',
1479
+ reason: observesFoundationPayload
1480
+ ? 'complete frozen payload observed by Foundation; marketplace installation and host invocation remain manual'
1481
+ : 'human consumer platform — installation is a manual post-release task; not verified by system',
1432
1482
  });
1433
1483
  adapterChecks.push({
1434
1484
  actionId: action.id,
1435
1485
  actionType: action.type,
1436
1486
  status: 'SKIPPED',
1437
- reason: 'manual-follow-up',
1487
+ reason: observesFoundationPayload
1488
+ ? 'foundation-payload-observed-manual-follow-up'
1489
+ : 'manual-follow-up',
1438
1490
  });
1439
1491
  await evidence.append({
1440
1492
  phase: 'verify-marketplace',
1441
1493
  actionId: action.id,
1442
1494
  actionType: action.type,
1443
1495
  status: 'SKIPPED',
1444
- reason: 'manual-follow-up',
1496
+ reason: observesFoundationPayload
1497
+ ? 'foundation-payload-observed-manual-follow-up'
1498
+ : 'manual-follow-up',
1445
1499
  });
1446
1500
  return;
1447
1501
  }
@@ -1727,6 +1781,8 @@ export async function verifyRelease(options) {
1727
1781
  }));
1728
1782
  adapterChecks.sort((a, b) => a.actionId.localeCompare(b.actionId));
1729
1783
  consumerVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
1784
+ foundationPluginVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
1785
+ manualFollowUps.sort((a, b) => a.actionId.localeCompare(b.actionId));
1730
1786
  consumerGateResults.sort((a, b) => a.id.localeCompare(b.id));
1731
1787
  const rejectedAction = actionResults.find((result) => result.status === 'rejected');
1732
1788
  if (rejectedAction) throw rejectedAction.reason;
@@ -2034,6 +2090,9 @@ export async function verifyRelease(options) {
2034
2090
  gateResults: consumerGateResults,
2035
2091
  consumerVerificationReceipts,
2036
2092
  skillResourceClosureReceipts,
2093
+ ...(foundationPluginVerificationReceipts.length > 0
2094
+ ? { foundationPluginVerificationReceipts }
2095
+ : {}),
2037
2096
  ...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
2038
2097
  startedAt: clockFn(),
2039
2098
  finishedAt: clockFn(),
@@ -2127,11 +2186,15 @@ export async function verifyRelease(options) {
2127
2186
 
2128
2187
  return {
2129
2188
  planPath,
2189
+ runPath: verifyRunPath,
2130
2190
  status: VERIFIED,
2131
2191
  adapterChecks,
2132
2192
  recoveryActionCode: null,
2133
2193
  smokeTest,
2134
2194
  gateResults: consumerGateResults,
2195
+ ...(foundationPluginVerificationReceipts.length > 0
2196
+ ? { foundationPluginVerificationReceipts }
2197
+ : {}),
2135
2198
  ...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
2136
2199
  ...(baselineAdvance ? { baselineAdvance } : {}),
2137
2200
  };
@@ -40,9 +40,6 @@
40
40
  import { canonicalJson, sha256Hex } from './digest.mjs';
41
41
  import { resolveProducerVersion } from './evidence.mjs';
42
42
 
43
- // Keep the assessment entry point compatible without duplicating the rule.
44
- export { findPostPublishUnitConflict } from './hooks.mjs';
45
-
46
43
  /** Four adoption statuses; never reuses ASSESSED (design §3.2). */
47
44
  export const ASSESSMENT_STATUS = Object.freeze({
48
45
  NOT_CONFIGURED: 'NOT_CONFIGURED',
@@ -12,7 +12,8 @@
12
12
  * - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
13
13
  *
14
14
  * The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
15
- * centralized here: for planVersion 2 plans the baseline is record-layer
15
+ * centralized here: for planVersion 2/3 plans (v3 inherits the v2 approval
16
+ * boundary per 多发布单元 postPublish v3 §4.2) the baseline is record-layer
16
17
  * data -- gitTreeHash/workspaceDigest equality and the production workspace
17
18
  * digest algorithm double-check are NOT invalidation conditions (artifact
18
19
  * integrity is sealed by the frozen-artifact re-verification at publish).
@@ -186,11 +187,13 @@ export function validateApproval(plan, approval, options = {}) {
186
187
  );
187
188
  }
188
189
 
189
- // planVersion fork (centralized here; see module header): v2 plans treat
190
- // the baseline as optional record-layer data.
191
- const planV2 = plan?.planVersion === 2;
190
+ // planVersion fork (centralized here; see module header): planVersion 2/3
191
+ // plans treat the baseline as optional record-layer data (v3 inherits the
192
+ // v2 approval boundary per 多发布单元 postPublish v3 §4.2). Versions beyond
193
+ // {2,3} stay on the legacy v1 path and are never silently admitted.
194
+ const recordLayerBaseline = plan?.planVersion === 2 || plan?.planVersion === 3;
192
195
 
193
- if (planV2) {
196
+ if (recordLayerBaseline) {
194
197
  if (!approval.planDigest || !approval.expiresAt) {
195
198
  throw new ReleaseError(
196
199
  GATE_FAILED,
@@ -206,7 +209,7 @@ export function validateApproval(plan, approval, options = {}) {
206
209
  );
207
210
  }
208
211
 
209
- if (!planV2) {
212
+ if (!recordLayerBaseline) {
210
213
  if (plan.production?.mode === 'github-npm-v1') {
211
214
  if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
212
215
  throw new ReleaseError(
@@ -249,9 +252,9 @@ export function validateApproval(plan, approval, options = {}) {
249
252
  }
250
253
 
251
254
  // --- baseline equality checks (v1 plans only) ---
252
- // For planVersion 2 plans the baseline is record-layer data: it stays in
255
+ // For planVersion 2/3 plans the baseline is record-layer data: it stays in
253
256
  // the plan/approval files for audit but is not an invalidation condition.
254
- if (!planV2) {
257
+ if (!recordLayerBaseline) {
255
258
  // --- baseline.gitTreeHash match ---
256
259
  if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
257
260
  throw new ReleaseError(
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Release-domain thin adapter for Foundation 0.13 complete-plugin observation.
3
+ *
4
+ * The frozen snapshot remains the only payload authority. This module derives
5
+ * transient Foundation inputs from that tree, calls the released Kit API, and
6
+ * returns only the fields needed to bind the mechanism observation to a
7
+ * release action. It does not prove a marketplace install or host invocation.
8
+ *
9
+ * @module core/foundation-plugin-verification
10
+ */
11
+
12
+ import { join } from 'node:path';
13
+
14
+ import { digestDocument } from 'skill-family-contracts';
15
+ import {
16
+ bundledHostProfilesRoot,
17
+ describeHost,
18
+ runPluginVerification,
19
+ } from 'skill-family-engineering-kit';
20
+ import {
21
+ createFilesystemRootBinding,
22
+ readFileBound,
23
+ } from 'skill-family-harness-node';
24
+
25
+ import { getPlatform, normalizeHostId } from '../platforms/registry.mjs';
26
+ import { verifyFrozenSnapshot } from '../snapshot/frozen.mjs';
27
+ import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
28
+
29
+ // Foundation's public plugin-verification request freezes the built-in driver
30
+ // contract at 1.0.0. The real API integration test intentionally exercises
31
+ // this value so an upstream contract change fails closed during adoption.
32
+ const FOUNDATION_PLUGIN_DRIVER_VERSION = '1.0.0';
33
+
34
+ function fail(message, details = {}) {
35
+ return new ReleaseError(POST_PUBLISH_VERIFY_FAILED, message, details);
36
+ }
37
+
38
+ function projectSourceMembers(entries) {
39
+ const directories = new Set();
40
+ for (const entry of entries) {
41
+ const parts = entry.path.split('/');
42
+ for (let count = 1; count < parts.length; count += 1) {
43
+ directories.add(parts.slice(0, count).join('/'));
44
+ }
45
+ }
46
+ return [
47
+ ...[...directories].map((path) => ({ path, type: 'directory' })),
48
+ ...entries.map((entry) => ({
49
+ path: entry.path,
50
+ type: 'file',
51
+ sha256: entry.contentDigest,
52
+ bytes: entry.size,
53
+ executable: (entry.mode & 0o111) !== 0,
54
+ })),
55
+ ].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
56
+ }
57
+
58
+ /**
59
+ * Return whether one human-consumer action has the complete frozen identity
60
+ * required by Foundation's local-staged plugin verification.
61
+ *
62
+ * This predicate is deliberately plan-derived. It does not persist a second
63
+ * action list, and the verifier repeats the same checks before every call.
64
+ */
65
+ export function isFoundationPluginVerificationEligible({ action, units = [] } = {}) {
66
+ const platformId = action?.parameters?.consumer;
67
+ const expectedType = platformId === 'kimi'
68
+ ? 'kimi-marketplace-install'
69
+ : platformId === 'codebuddy'
70
+ ? 'codebuddy-marketplace-install'
71
+ : null;
72
+ if (!expectedType || action?.type !== expectedType) return false;
73
+
74
+ const unit = units.find((candidate) => candidate.id === action.unitId);
75
+ const snapshot = unit?.frozenSnapshot;
76
+ const sourceDescriptor = action.parameters?.sourceDescriptor;
77
+ if (
78
+ !snapshot?.path
79
+ || !snapshot.commit
80
+ || !snapshot.manifestDigest
81
+ || sourceDescriptor?.form !== 'bundled-family'
82
+ || sourceDescriptor.pluginSubpath !== '.'
83
+ || sourceDescriptor.commit !== snapshot.commit
84
+ || sourceDescriptor.payloadDigest !== snapshot.manifestDigest
85
+ || action.parameters?.sourceCommit !== snapshot.commit
86
+ || action.parameters?.snapshotPath !== snapshot.path
87
+ || action.parameters?.manifestDigest !== snapshot.manifestDigest
88
+ ) return false;
89
+
90
+ const platform = getPlatform(platformId);
91
+ return (unit.distributions ?? []).some((distribution) => (
92
+ distribution.type === platform.distributionType
93
+ && typeof distribution.installationContract?.manifestRelativePath === 'string'
94
+ && distribution.installationContract.manifestRelativePath.length > 0
95
+ ));
96
+ }
97
+
98
+ function assertResultMatchesRequest(result, request) {
99
+ if (
100
+ result?.status !== 'observed'
101
+ || result.requestDigest !== digestDocument(request)
102
+ || result.goal !== request.goal
103
+ || digestDocument(result.source) !== digestDocument(request.source)
104
+ || digestDocument(result.host) !== digestDocument(request.host)
105
+ || result.facts?.install?.payloadMatches !== true
106
+ || result.facts.install.sourceMode !== 'local-staged'
107
+ || result.facts.install.retained !== true
108
+ || result.facts.invocation !== null
109
+ ) {
110
+ throw fail('Foundation complete-plugin observation did not satisfy the frozen local payload contract', {
111
+ foundationStatus: result?.status ?? null,
112
+ foundationReason: result?.reason ?? null,
113
+ payloadMatches: result?.facts?.install?.payloadMatches ?? null,
114
+ requiredActions: result?.requiredActions ?? [],
115
+ });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Observe one frozen Kimi or CodeBuddy plugin payload through Foundation.
121
+ *
122
+ * @param {object} input
123
+ * @param {object} input.plan
124
+ * @param {object} input.action
125
+ * @param {string} input.root
126
+ * @param {string} input.runDir - Fresh per-action directory containing empty
127
+ * install, temporary and evidence children.
128
+ * @param {Function} [input.runPluginVerificationFn]
129
+ * @param {Function} [input.clock]
130
+ * @returns {Promise<object>} local payload-observation receipt
131
+ */
132
+ export async function verifyFrozenPluginWithFoundation({
133
+ plan,
134
+ action,
135
+ root,
136
+ runDir,
137
+ runPluginVerificationFn = runPluginVerification,
138
+ clock = () => new Date().toISOString(),
139
+ } = {}) {
140
+ const unit = (plan?.units ?? []).find((candidate) => candidate.id === action?.unitId);
141
+ if (!unit?.frozenSnapshot?.path || !unit.frozenSnapshot.commit) {
142
+ throw fail('Foundation plugin observation requires a frozen unit snapshot', {
143
+ actionId: action?.id,
144
+ unitId: action?.unitId,
145
+ });
146
+ }
147
+
148
+ if (!isFoundationPluginVerificationEligible({ action, units: plan.units })) {
149
+ throw fail('Foundation local plugin observation requires one identity-bound bundled-family payload', {
150
+ actionId: action?.id,
151
+ sourceForm: action?.parameters?.sourceDescriptor?.form ?? null,
152
+ pluginSubpath: action?.parameters?.sourceDescriptor?.pluginSubpath ?? null,
153
+ });
154
+ }
155
+
156
+ const platformId = action?.parameters?.consumer;
157
+ const platform = getPlatform(platformId);
158
+ if (!['kimi', 'codebuddy'].includes(platform.id)) {
159
+ throw fail('Foundation local plugin observation is not configured for this release platform', {
160
+ actionId: action?.id,
161
+ platform: platform.id,
162
+ });
163
+ }
164
+ const sourceDescriptor = action.parameters.sourceDescriptor;
165
+ const distribution = (unit.distributions ?? []).find(
166
+ (candidate) => candidate.type === platform.distributionType,
167
+ );
168
+ const manifestRelativePath = distribution?.installationContract?.manifestRelativePath;
169
+ if (!manifestRelativePath) {
170
+ throw fail('Foundation plugin observation requires the frozen installation manifest path', {
171
+ actionId: action.id,
172
+ distributionType: platform.distributionType,
173
+ });
174
+ }
175
+
176
+ const frozen = await verifyFrozenSnapshot({
177
+ root,
178
+ snapshotPath: unit.frozenSnapshot.path,
179
+ expectedDigest: unit.frozenSnapshot.manifestDigest,
180
+ });
181
+ const sourceRoot = frozen.snapshotDir;
182
+ const sourceMembers = projectSourceMembers(frozen.entries);
183
+ const sourceManifest = frozen.entries.find((entry) => entry.path === manifestRelativePath);
184
+ if (!sourceManifest) {
185
+ throw fail('Foundation plugin observation manifest is not part of the frozen payload', {
186
+ actionId: action.id,
187
+ manifestRelativePath,
188
+ });
189
+ }
190
+
191
+ const hostsRoot = bundledHostProfilesRoot();
192
+ const hostId = await normalizeHostId(platform.buildAdapter.name);
193
+ const descriptor = await describeHost({ hostId, hostsRoot });
194
+ const hostsBinding = await createFilesystemRootBinding(hostsRoot);
195
+ const descriptorFile = await readFileBound(hostsRoot, `${hostId}/host-descriptor.json`, {
196
+ rootBinding: hostsBinding,
197
+ });
198
+ if (!descriptor.verification?.driverId) {
199
+ throw fail('Foundation host has no complete-plugin verification driver', { hostId });
200
+ }
201
+
202
+ const request = {
203
+ schemaVersion: 1,
204
+ kind: 'skill-family.plugin-verification-request',
205
+ operation: 'plugin-verification',
206
+ verificationSetId: `${plan.digest}:${action.id}`,
207
+ goal: 'install-only',
208
+ source: {
209
+ type: 'local-staged',
210
+ candidateRef: sourceDescriptor.commit,
211
+ sourceManifestSha256: sourceManifest.contentDigest,
212
+ membersDigest: digestDocument(sourceMembers),
213
+ },
214
+ host: {
215
+ hostId,
216
+ descriptorSha256: descriptorFile.sha256,
217
+ driverId: descriptor.verification.driverId,
218
+ driverVersion: FOUNDATION_PLUGIN_DRIVER_VERSION,
219
+ },
220
+ install: { mode: 'fresh-tree' },
221
+ };
222
+ const bindings = {
223
+ sourceRoot,
224
+ sourceManifestRelPath: manifestRelativePath,
225
+ sourceMembers,
226
+ installContainerRoot: join(runDir, 'install'),
227
+ temporaryRoot: join(runDir, 'temporary'),
228
+ privateEvidenceRoot: join(runDir, 'evidence'),
229
+ };
230
+
231
+ const result = await runPluginVerificationFn({ request, bindings, hostsRoot });
232
+ assertResultMatchesRequest(result, request);
233
+
234
+ return {
235
+ planDigest: plan.digest,
236
+ actionId: action.id,
237
+ unitId: unit.id,
238
+ requestDigest: result.requestDigest,
239
+ hostId,
240
+ goal: request.goal,
241
+ sourceType: request.source.type,
242
+ foundationStatus: result.status,
243
+ payloadMatches: result.facts.install.payloadMatches,
244
+ observationDigest: result.facts.install.observationDigest,
245
+ observedAt: clock(),
246
+ };
247
+ }
@@ -43,22 +43,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
43
43
  // Validation
44
44
  // ---------------------------------------------------------------------------
45
45
 
46
- /**
47
- * A release plan binds at most one postPublish declaration. Both prepare
48
- * and adoption assessment consume this pure rule and retain their own
49
- * error codes and presentation. Declaration contents are validated by
50
- * the existing postPublish validator, not by this cross-unit check.
51
- *
52
- * @param {Array<Object>} units - config.releaseUnits.
53
- * @returns {null|{unitIds: string[]}} Sorted conflicting unit IDs, or null.
54
- */
55
- export function findPostPublishUnitConflict(units) {
56
- const declaring = (units ?? [])
57
- .filter((unit) => unit && typeof unit === 'object' && unit.postPublish !== undefined);
58
- if (declaring.length <= 1) return null;
59
- return { unitIds: declaring.map((unit) => unit.id).sort() };
60
- }
61
-
62
46
  /**
63
47
  * Validate the shape of a hook descriptor against the project contract.
64
48
  *
package/src/core/plan.mjs CHANGED
@@ -202,7 +202,8 @@ export function validatePlan(plan) {
202
202
  }
203
203
 
204
204
  /**
205
- * planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1).
205
+ * planVersion 2/3 digest scope (design: t1-2-digest-decoupling.md §4.1;
206
+ * v3 inherits the v2 scope per 多发布单元 postPublish v3 §4.2).
206
207
  *
207
208
  * The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
208
209
  * the whole `baseline` object, and each action's runtime `status` -- is
@@ -210,8 +211,10 @@ export function validatePlan(plan) {
210
211
  * but is excluded from the digest. Everything else (planVersion, units with
211
212
  * every frozenSnapshot artifact identity field, externalActions identity,
212
213
  * production, configDigest, verificationGates, snapshotDigest) binds.
214
+ * For v3 the frozen `postPublish` array and every item binding field stay in
215
+ * the binding layer: any order or binding change moves the digest.
213
216
  *
214
- * This is the ONLY place the v2 record layer is defined.
217
+ * This is the ONLY place the v2/v3 record layer is defined.
215
218
  *
216
219
  * @param {object} plan - A plan object.
217
220
  * @returns {object} The binding-layer projection of the plan.
@@ -242,9 +245,12 @@ function stripRecordLayerV2(plan) {
242
245
  *
243
246
  * Digest scope forks on `plan.planVersion` (centralized here, never spread
244
247
  * elsewhere):
245
- * - planVersion 2: record-layer fields (status/createdAt/baseline/action
248
+ * - planVersion 2/3: record-layer fields (status/createdAt/baseline/action
246
249
  * status/digest) are stripped before hashing, so re-preparing over the
247
- * same sources yields the same digest. See `stripRecordLayerV2`.
250
+ * same sources yields the same digest. See `stripRecordLayerV2`. v3
251
+ * inherits the v2 scope exactly (multi-release-unit postPublish v3 §4.2);
252
+ * versions beyond {2,3} are NOT admitted and stay fail-closed on the
253
+ * legacy v1 path rather than being silently bound to v2 semantics.
248
254
  * - planVersion 1 or absent (legacy): only the top-level `digest` field is
249
255
  * stripped. Byte-for-byte legacy semantics -- this path must never change.
250
256
  *
@@ -258,7 +264,7 @@ export function computePlanDigest(plan) {
258
264
  // 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
259
265
  // 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
260
266
  // 完全一致,对含 undefined 的计划保持迁移前字节语义。
261
- if (plan.planVersion === 2) {
267
+ if (plan.planVersion === 2 || plan.planVersion === 3) {
262
268
  return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
263
269
  }
264
270
  // Strip the digest field if present so the hash is self-consistent.
@@ -341,14 +347,15 @@ export async function writePlanImmutable(planPath, plan) {
341
347
  // EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
342
348
  const existing = await readFile(planPath, 'utf8').catch(() => null);
343
349
  if (existing === json) return { planPath, planDigest };
344
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.1): record-layer
345
- // fields (status/createdAt/baseline) are excluded from the digest, so
350
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.1; v3 inherits
351
+ // the scope per 多发布单元 postPublish v3 §4.2): record-layer fields
352
+ // (status/createdAt/baseline) are excluded from the digest, so
346
353
  // re-preparing over identical binding content yields the SAME digest
347
354
  // with legitimately different bytes. Reuse the existing authority IFF
348
355
  // it is genuinely self-consistent (its embedded digest and recomputed
349
356
  // digest both equal this digest) -- the authority is never replaced.
350
357
  // Any other divergence (tampering, malformed file) still fails closed.
351
- if (plan.planVersion === 2 && existing !== null) {
358
+ if ((plan.planVersion === 2 || plan.planVersion === 3) && existing !== null) {
352
359
  let existingPlan = null;
353
360
  try {
354
361
  existingPlan = JSON.parse(existing);
@@ -386,7 +393,7 @@ export async function writePlanImmutable(planPath, plan) {
386
393
  /**
387
394
  * Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
388
395
  * Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
389
- * schema; used by the planVersion 2 completeness gate, which requires a
396
+ * schema; used by the planVersion 2/3 completeness gate, which requires a
390
397
  * canonical normalized timestamp without re-binding it to plan.createdAt.
391
398
  */
392
399
  const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
@@ -568,8 +575,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
568
575
  }
569
576
  if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
570
577
  failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
571
- } else if (plan.planVersion === 2) {
572
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.2): the freeze
578
+ } else if (plan.planVersion === 2 || plan.planVersion === 3) {
579
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.2; v3 inherits
580
+ // the semantics per 多发布单元 postPublish v3 §4.2): the freeze
573
581
  // timestamp is derived from the headCommit committer date, so it no
574
582
  // longer equals plan.createdAt (record-layer real clock). It must
575
583
  // still be a canonical normalized timestamp -- the same pattern the
@@ -47,6 +47,7 @@ import { ReleaseError, GATE_FAILED } from './errors.mjs';
47
47
  import { computePlanDigest } from './plan.mjs';
48
48
  import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
49
49
  import { resolvePresetRequiresApproval } from './presets.mjs';
50
+ import { normalizePostPublishView, validatePostPublishHookIdUniqueness } from './postpublish.mjs';
50
51
  import { readTrustedPackageResource } from './trusted-resource.mjs';
51
52
 
52
53
  const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
@@ -79,7 +80,8 @@ export function validatePostPublishApprovalRecordSchema(approval) {
79
80
  * - record shape (postpublish-approval-record schema; additionalProperties
80
81
  * is false, so plan-level fields like approvedActions are rejected);
81
82
  * - planDigest equals the computed plan digest;
82
- * - hookId names a hook declared in the frozen plan's postPublish.hooks;
83
+ * - hookId names a hook declared in the frozen plan's postPublish
84
+ * declarations (unified normalized view, globally unique across units);
83
85
  * - that hook actually declares requiresApproval: true;
84
86
  * - the shared approval time window (24h max, 5-minute skew, unexpired).
85
87
  *
@@ -103,7 +105,15 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
103
105
  );
104
106
  }
105
107
 
106
- const hooks = plan.postPublish?.hooks ?? [];
108
+ // §4.3 unified normalization (rework R-02): the frozen plan's postPublish
109
+ // may be the legacy single declaration (planVersion 1/2) or the v3
110
+ // declaration array; every consumer resolves declared hooks through the
111
+ // SAME normalized view, and the SAME array-level uniqueness authority
112
+ // re-asserts that hookId is globally unique across declarations — one
113
+ // (planDigest, hookId) approval must never authorize two units.
114
+ const declarations = normalizePostPublishView(plan);
115
+ validatePostPublishHookIdUniqueness(declarations);
116
+ const hooks = declarations.flatMap((declaration) => declaration.hooks ?? []);
107
117
  const hook = hooks.find((entry) => entry.id === approval.hookId);
108
118
  if (!hook) {
109
119
  throw new ReleaseError(