release-skill 0.1.10 → 0.2.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +17 -32
  9. package/README.zh-CN.md +17 -25
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2721 -1843
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2721 -1843
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -736
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +5 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
package/src/core/plan.mjs CHANGED
@@ -22,6 +22,13 @@ import addFormats from 'ajv-formats';
22
22
  import { canonicalJson, sha256Hex } from './digest.mjs';
23
23
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
24
  import { readTrustedPackageResource } from './trusted-resource.mjs';
25
+ // NOTE (T2.2 step 3): plan.mjs and the platform registry form a module cycle
26
+ // (plan -> registry -> platforms/kimi -> plan, because the kimi strategies
27
+ // bind computePlanDigest). PLATFORMS is therefore only ever read inside
28
+ // function bodies (after the import graph has settled), never at plan.mjs
29
+ // module-init time — a top-level read would hit the registry's
30
+ // not-yet-initialized bindings when the cycle is entered registry-first.
31
+ import { PLATFORMS } from '../platforms/registry.mjs';
25
32
 
26
33
  // ---------------------------------------------------------------------------
27
34
  // Schema loaded from the authoritative JSON file (single source of truth)
@@ -159,6 +166,37 @@ export function validatePlan(plan) {
159
166
  }
160
167
  }
161
168
 
169
+ /**
170
+ * planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1).
171
+ *
172
+ * The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
173
+ * the whole `baseline` object, and each action's runtime `status` -- is
174
+ * still written to the plan file (schema unchanged, evidence chain intact)
175
+ * but is excluded from the digest. Everything else (planVersion, units with
176
+ * every frozenSnapshot artifact identity field, externalActions identity,
177
+ * production, configDigest, verificationGates, snapshotDigest) binds.
178
+ *
179
+ * This is the ONLY place the v2 record layer is defined.
180
+ *
181
+ * @param {object} plan - A plan object.
182
+ * @returns {object} The binding-layer projection of the plan.
183
+ */
184
+ function stripRecordLayerV2(plan) {
185
+ const {
186
+ digest: _digest,
187
+ status: _status,
188
+ createdAt: _createdAt,
189
+ baseline: _baseline,
190
+ ...rest
191
+ } = plan;
192
+ return {
193
+ ...rest,
194
+ externalActions: (plan.externalActions ?? []).map(
195
+ ({ status: _actionStatus, ...action }) => action,
196
+ ),
197
+ };
198
+ }
199
+
162
200
  /**
163
201
  * Compute a deterministic SHA-256 digest of a plan object.
164
202
  *
@@ -167,11 +205,22 @@ export function validatePlan(plan) {
167
205
  * self-consistent: `computePlanDigest(plan) === plan.digest` when the plan
168
206
  * was written by `writePlanAtomic`.
169
207
  *
208
+ * Digest scope forks on `plan.planVersion` (centralized here, never spread
209
+ * elsewhere):
210
+ * - planVersion 2: record-layer fields (status/createdAt/baseline/action
211
+ * status/digest) are stripped before hashing, so re-preparing over the
212
+ * same sources yields the same digest. See `stripRecordLayerV2`.
213
+ * - planVersion 1 or absent (legacy): only the top-level `digest` field is
214
+ * stripped. Byte-for-byte legacy semantics -- this path must never change.
215
+ *
170
216
  * @param {object} plan - A plan object (must not include a `digest` field,
171
217
  * or the field will be stripped before hashing).
172
218
  * @returns {string} Lowercase 64-char hex SHA-256 digest.
173
219
  */
174
220
  export function computePlanDigest(plan) {
221
+ if (plan.planVersion === 2) {
222
+ return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
223
+ }
175
224
  // Strip the digest field if present so the hash is self-consistent.
176
225
  const { digest: _digest, ...rest } = plan;
177
226
  return sha256Hex(canonicalJson(rest));
@@ -260,6 +309,28 @@ export async function writePlanImmutable(planPath, plan) {
260
309
  if (error.code !== 'EEXIST') throw error;
261
310
  const existing = await readFile(planPath, 'utf8');
262
311
  if (existing !== json) {
312
+ // planVersion 2 (design: t1-2-digest-decoupling.md §4.1): record-layer
313
+ // fields (status/createdAt/baseline) are excluded from the digest, so
314
+ // re-preparing over identical binding content yields the SAME digest
315
+ // with legitimately different bytes. Reuse the existing authority IFF
316
+ // it is genuinely self-consistent (its embedded digest and recomputed
317
+ // digest both equal this digest) -- the authority is never replaced.
318
+ // Any other divergence (tampering, malformed file) still fails closed.
319
+ if (plan.planVersion === 2) {
320
+ let existingPlan = null;
321
+ try {
322
+ existingPlan = JSON.parse(existing);
323
+ } catch {
324
+ existingPlan = null;
325
+ }
326
+ if (
327
+ existingPlan && typeof existingPlan === 'object' &&
328
+ existingPlan.digest === planDigest &&
329
+ computePlanDigest(existingPlan) === planDigest
330
+ ) {
331
+ return { planPath, planDigest };
332
+ }
333
+ }
263
334
  throw new ReleaseError(
264
335
  GATE_FAILED,
265
336
  'immutable plan authority already exists with different bytes',
@@ -277,17 +348,33 @@ export async function writePlanImmutable(planPath, plan) {
277
348
  // Plan action completeness gate
278
349
  // ---------------------------------------------------------------------------
279
350
 
280
- /** Expected adapter for each action type. */
281
- const EXPECTED_ADAPTER = {
282
- 'push-snapshot': 'git-github',
283
- 'create-tag': 'git-github',
284
- 'github-release': 'github',
285
- 'npm-publish': 'npm',
286
- 'claude-marketplace-install': 'plugin-marketplace',
287
- 'codex-marketplace-install': 'plugin-marketplace',
288
- 'kimi-marketplace-install': 'plugin-marketplace',
289
- 'set-default-branch': 'git-github',
290
- };
351
+ /**
352
+ * Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
353
+ * Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
354
+ * schema; used by the planVersion 2 completeness gate, which requires a
355
+ * canonical normalized timestamp without re-binding it to plan.createdAt.
356
+ */
357
+ const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
358
+
359
+ /**
360
+ * Build the expected-adapter map for each action type. The marketplace
361
+ * install entries are derived from the platform registry (the single source
362
+ * of platform knowledge: actionType -> adapter); the remaining entries are
363
+ * fixed non-platform action types.
364
+ *
365
+ * Built lazily on demand (see the PLATFORMS import note above): this must
366
+ * never run at plan.mjs module-init time.
367
+ */
368
+ function buildExpectedAdapterMap() {
369
+ return {
370
+ 'push-snapshot': 'git-github',
371
+ 'create-tag': 'git-github',
372
+ 'github-release': 'github',
373
+ 'npm-publish': 'npm',
374
+ ...Object.fromEntries(PLATFORMS.map((p) => [p.actionType, p.adapter])),
375
+ 'set-default-branch': 'git-github',
376
+ };
377
+ }
291
378
 
292
379
  /** Required action types for every unit. */
293
380
  const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
@@ -300,9 +387,9 @@ const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
300
387
  * - 1 create-tag (adapter: git-github)
301
388
  * - 1 github-release (adapter: github)
302
389
  * - 1 npm-publish (adapter: npm) — only if the unit has an npm distribution
303
- * - 1 claude-marketplace-install (adapter: plugin-marketplace) — only if the unit has a claude-plugin distribution
304
- * - 1 codex-marketplace-install (adapter: plugin-marketplace) only if the unit has a codex-plugin distribution
305
- * - 1 kimi-marketplace-install (adapter: plugin-marketplace) only if the unit has a kimi-plugin distribution
390
+ * - 1 <platform>-marketplace-install (adapter from the platform registry)
391
+ * one per registry platform whose distribution the unit declares
392
+ * (currently claude / codex / kimi; driven by src/platforms/registry.mjs)
306
393
  *
307
394
  * Every action must bind to the correct unitId, correct adapter, correct
308
395
  * version, correct publicRepo (where applicable), and correct tag derived
@@ -329,6 +416,7 @@ export function validatePlanActionCompleteness(plan, options = {}) {
329
416
 
330
417
  const units = Array.isArray(plan.units) ? plan.units : [];
331
418
  const actions = Array.isArray(plan.externalActions) ? plan.externalActions : [];
419
+ const expectedAdapterMap = buildExpectedAdapterMap();
332
420
 
333
421
  // --- Must have at least one action ---
334
422
  if (actions.length === 0) {
@@ -394,6 +482,15 @@ export function validatePlanActionCompleteness(plan, options = {}) {
394
482
  }
395
483
  if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
396
484
  failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
485
+ } else if (plan.planVersion === 2) {
486
+ // planVersion 2 (design: t1-2-digest-decoupling.md §4.2): the freeze
487
+ // timestamp is derived from the headCommit committer date, so it no
488
+ // longer equals plan.createdAt (record-layer real clock). It must
489
+ // still be a canonical normalized timestamp -- the same pattern the
490
+ // release-plan schema enforces.
491
+ if (!CANONICAL_COMMIT_TIMESTAMP_RE.test(frozen.commitTimestamp)) {
492
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is not a canonical normalized timestamp (expected YYYY-MM-DDTHH:MM:SS+00:00)`);
493
+ }
397
494
  } else if (plan.createdAt !== frozen.commitTimestamp) {
398
495
  failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
399
496
  }
@@ -425,7 +522,7 @@ export function validatePlanActionCompleteness(plan, options = {}) {
425
522
  // Required actions for this unit (always required)
426
523
  for (const actionType of REQUIRED_ACTION_TYPES) {
427
524
  expectedCount++;
428
- const expectedAdapter = EXPECTED_ADAPTER[actionType];
525
+ const expectedAdapter = expectedAdapterMap[actionType];
429
526
  const expectedActionId = `${actionType}-${unitId}`;
430
527
  const matchingActions = actions.filter(
431
528
  (a) => a.unitId === unitId && a.type === actionType,
@@ -617,242 +714,46 @@ export function validatePlanActionCompleteness(plan, options = {}) {
617
714
  }
618
715
  }
619
716
 
620
- // claude-marketplace-install: only required if unit has claude-plugin distribution
621
- const claudeDist = distributions.find((d) => d.type === 'claude-plugin');
622
- if (claudeDist) {
623
- const plugin = claudeDist.plugin;
624
- const marketplace = claudeDist.marketplace;
625
- const entrySkill = claudeDist.entrySkill;
626
- if (!plugin || !marketplace || !entrySkill) {
627
- failures.push(`unit "${unitId}": claude-plugin distribution requires plugin, marketplace, and entrySkill`);
628
- }
629
- expectedCount++;
630
- const expectedActionId = `claude-marketplace-install-${unitId}`;
631
- const claudeActions = actions.filter(
632
- (a) => a.unitId === unitId && a.type === 'claude-marketplace-install',
633
- );
634
-
635
- if (claudeActions.length === 0) {
636
- failures.push(
637
- `unit "${unitId}": claude-plugin distribution declared but "claude-marketplace-install" action is missing`,
638
- );
639
- } else if (claudeActions.length > 1) {
640
- failures.push(
641
- `unit "${unitId}": duplicate claude-marketplace-install actions (${claudeActions.length} found, expected 1)`,
642
- );
643
- } else {
644
- const action = claudeActions[0];
645
-
646
- if (action.id !== expectedActionId) {
647
- failures.push(
648
- `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
649
- );
650
- }
651
- if (action.unitId !== unitId) {
652
- failures.push(
653
- `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
654
- );
655
- }
656
- if (action.adapter !== 'plugin-marketplace') {
657
- failures.push(
658
- `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
659
- );
660
- }
661
- if (action.status !== 'PENDING') {
662
- failures.push(
663
- `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
664
- );
665
- }
666
-
667
- // Parameter checks
668
- _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'claude', unitId, failures);
669
- _checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
670
- _checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
671
- _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
672
- _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
673
- _checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
674
- if (production) {
675
- _checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
676
- _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
677
- _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
678
- }
679
- // timeoutMs is mandatory for all marketplace install actions.
680
- // Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
681
- // relaxes the check for reconcile/verify paths only, and only when
682
- // the property is genuinely absent (undefined). An explicit null is
683
- // NOT "absent" -- it is an invalid value and always fails closed,
684
- // like strings or out-of-range numbers, even in legacyCompatibility.
685
- {
686
- const raw = action.parameters?.timeoutMs;
687
- if (raw === undefined) {
688
- if (!options.legacyCompatibility) {
689
- failures.push(
690
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
691
- );
692
- }
693
- } else {
694
- // Field is present -- always validate range/type, even in legacy mode
695
- if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
696
- failures.push(
697
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
698
- );
699
- } else if (raw < 30000 || raw > 900000) {
700
- failures.push(
701
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
702
- );
703
- }
704
- }
705
- }
706
-
707
- // Expected checks
708
- _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
709
- _checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
710
- _checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
711
- _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
712
- _checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
713
- if (production) {
714
- _checkRequired(action, 'expected.consumer', action.expected?.consumer, 'claude', unitId, failures);
715
- _checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
716
- _checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
717
- _checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
718
- _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
719
- }
720
- }
721
- }
722
-
723
- // codex-marketplace-install: only required if unit has codex-plugin distribution
724
- const codexDist = distributions.find((d) => d.type === 'codex-plugin');
725
- if (codexDist) {
726
- const plugin = codexDist.plugin;
727
- const marketplace = codexDist.marketplace;
728
- const entrySkill = codexDist.entrySkill;
729
- if (!plugin || !marketplace || !entrySkill) {
730
- failures.push(`unit "${unitId}": codex-plugin distribution requires plugin, marketplace, and entrySkill`);
731
- }
732
- expectedCount++;
733
- const expectedActionId = `codex-marketplace-install-${unitId}`;
734
- const codexActions = actions.filter(
735
- (a) => a.unitId === unitId && a.type === 'codex-marketplace-install',
736
- );
737
-
738
- if (codexActions.length === 0) {
739
- failures.push(
740
- `unit "${unitId}": codex-plugin distribution declared but "codex-marketplace-install" action is missing`,
741
- );
742
- } else if (codexActions.length > 1) {
743
- failures.push(
744
- `unit "${unitId}": duplicate codex-marketplace-install actions (${codexActions.length} found, expected 1)`,
745
- );
746
- } else {
747
- const action = codexActions[0];
748
-
749
- if (action.id !== expectedActionId) {
750
- failures.push(
751
- `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
752
- );
753
- }
754
- if (action.unitId !== unitId) {
755
- failures.push(
756
- `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
757
- );
758
- }
759
- if (action.adapter !== 'plugin-marketplace') {
760
- failures.push(
761
- `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
762
- );
763
- }
764
- if (action.status !== 'PENDING') {
765
- failures.push(
766
- `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
767
- );
768
- }
769
-
770
- // Parameter checks
771
- _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'codex', unitId, failures);
772
- _checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
773
- _checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
774
- _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
775
- _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
776
- _checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
777
- if (production) {
778
- _checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
779
- _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
780
- _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
781
- }
782
- // timeoutMs is mandatory for all marketplace install actions.
783
- // Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
784
- // relaxes the check for reconcile/verify paths only, and only when
785
- // the property is genuinely absent (undefined). An explicit null is
786
- // NOT "absent" -- it is an invalid value and always fails closed,
787
- // like strings or out-of-range numbers, even in legacyCompatibility.
788
- {
789
- const raw = action.parameters?.timeoutMs;
790
- if (raw === undefined) {
791
- if (!options.legacyCompatibility) {
792
- failures.push(
793
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
794
- );
795
- }
796
- } else {
797
- // Field is present -- always validate range/type, even in legacy mode
798
- if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
799
- failures.push(
800
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
801
- );
802
- } else if (raw < 30000 || raw > 900000) {
803
- failures.push(
804
- `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
805
- );
806
- }
807
- }
808
- }
809
-
810
- // Expected checks
811
- _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
812
- _checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
813
- _checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
814
- _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
815
- _checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
816
- if (production) {
817
- _checkRequired(action, 'expected.consumer', action.expected?.consumer, 'codex', unitId, failures);
818
- _checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
819
- _checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
820
- _checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
821
- _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
822
- }
823
- }
824
- }
825
-
826
- // kimi-marketplace-install: only required if unit has kimi-plugin distribution
827
- const kimiDist = distributions.find((d) => d.type === 'kimi-plugin');
828
- if (kimiDist) {
829
- const plugin = kimiDist.plugin;
830
- // marketplace is NOT a required identity field for kimi: Kimi Code has an
831
- // interactive marketplace but no non-interactive install API, so the kimi
832
- // action carries no executable marketplace identity (MINOR-1). Only plugin
833
- // and entrySkill are required; a declared marketplace is tolerated as an
834
- // optional legacy value but never bound as a required condition.
835
- const marketplace = kimiDist.marketplace;
836
- const entrySkill = kimiDist.entrySkill;
837
- if (!plugin || !entrySkill) {
838
- failures.push(`unit "${unitId}": kimi-plugin distribution requires plugin and entrySkill`);
717
+ // Consumer marketplace install actions: one required per declared
718
+ // platform distribution, driven by the platform registry (T2.2 step 3).
719
+ // The loop body is shared by every platform; the registry declares the
720
+ // per-platform differences (actionType, distributionType, adapter,
721
+ // consumer id, and — via the schema required fields — whether marketplace
722
+ // identity is a required binding). Platforms without a required
723
+ // marketplace (kimi, MINOR-1: Kimi Code has an interactive marketplace
724
+ // but no non-interactive install API) tolerate a declared marketplace as
725
+ // an optional legacy value but never bind it as a required condition, in
726
+ // parameters or in expected.
727
+ for (const platform of PLATFORMS) {
728
+ const dist = distributions.find((d) => d.type === platform.distributionType);
729
+ if (!dist) continue;
730
+ const plugin = dist.plugin;
731
+ const marketplace = dist.marketplace;
732
+ const entrySkill = dist.entrySkill;
733
+ const requiresMarketplace = platform.schemaRequiredFields.includes('marketplace');
734
+ if (requiresMarketplace) {
735
+ if (!plugin || !marketplace || !entrySkill) {
736
+ failures.push(`unit "${unitId}": ${platform.distributionType} distribution requires plugin, marketplace, and entrySkill`);
737
+ }
738
+ } else if (!plugin || !entrySkill) {
739
+ failures.push(`unit "${unitId}": ${platform.distributionType} distribution requires plugin and entrySkill`);
839
740
  }
840
741
  expectedCount++;
841
- const expectedActionId = `kimi-marketplace-install-${unitId}`;
842
- const kimiActions = actions.filter(
843
- (a) => a.unitId === unitId && a.type === 'kimi-marketplace-install',
742
+ const expectedActionId = `${platform.actionType}-${unitId}`;
743
+ const matchingActions = actions.filter(
744
+ (a) => a.unitId === unitId && a.type === platform.actionType,
844
745
  );
845
746
 
846
- if (kimiActions.length === 0) {
747
+ if (matchingActions.length === 0) {
847
748
  failures.push(
848
- `unit "${unitId}": kimi-plugin distribution declared but "kimi-marketplace-install" action is missing`,
749
+ `unit "${unitId}": ${platform.distributionType} distribution declared but "${platform.actionType}" action is missing`,
849
750
  );
850
- } else if (kimiActions.length > 1) {
751
+ } else if (matchingActions.length > 1) {
851
752
  failures.push(
852
- `unit "${unitId}": duplicate kimi-marketplace-install actions (${kimiActions.length} found, expected 1)`,
753
+ `unit "${unitId}": duplicate ${platform.actionType} actions (${matchingActions.length} found, expected 1)`,
853
754
  );
854
755
  } else {
855
- const action = kimiActions[0];
756
+ const action = matchingActions[0];
856
757
 
857
758
  if (action.id !== expectedActionId) {
858
759
  failures.push(
@@ -864,9 +765,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
864
765
  `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
865
766
  );
866
767
  }
867
- if (action.adapter !== 'plugin-marketplace') {
768
+ if (action.adapter !== platform.adapter) {
868
769
  failures.push(
869
- `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
770
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "${platform.adapter}"`,
870
771
  );
871
772
  }
872
773
  if (action.status !== 'PENDING') {
@@ -876,13 +777,17 @@ export function validatePlanActionCompleteness(plan, options = {}) {
876
777
  }
877
778
 
878
779
  // Parameter checks
879
- _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'kimi', unitId, failures);
780
+ _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, platform.id, unitId, failures);
880
781
  _checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
881
- // marketplace is optional for kimi (MINOR-1). If a legacy plan still
882
- // carries it, it must be consistent with the declared distribution but
883
- // is never a required identity condition.
884
- if (action.parameters?.marketplace !== undefined && marketplace !== undefined
885
- && action.parameters.marketplace !== marketplace) {
782
+ if (requiresMarketplace) {
783
+ _checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
784
+ } else if (
785
+ action.parameters?.marketplace !== undefined && marketplace !== undefined
786
+ && action.parameters.marketplace !== marketplace
787
+ ) {
788
+ // marketplace is optional here (MINOR-1): a legacy plan may still
789
+ // carry it, but it must be consistent with the declared
790
+ // distribution and is never a required identity condition.
886
791
  failures.push(
887
792
  `unit "${unitId}", action "${action.id}": parameters.marketplace is "${action.parameters.marketplace}", expected optional legacy value "${marketplace}"`,
888
793
  );
@@ -923,14 +828,18 @@ export function validatePlanActionCompleteness(plan, options = {}) {
923
828
  }
924
829
  }
925
830
 
926
- // Expected checks. marketplace is NOT part of the kimi expected
927
- // identity (MINOR-1): the kimi observation never binds a marketplace.
831
+ // Expected checks. Marketplace identity is bound only on platforms
832
+ // whose schema requires it (MINOR-1): a non-marketplace platform's
833
+ // observation never binds a marketplace.
928
834
  _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
929
835
  _checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
836
+ if (requiresMarketplace) {
837
+ _checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
838
+ }
930
839
  _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
931
840
  _checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
932
841
  if (production) {
933
- _checkRequired(action, 'expected.consumer', action.expected?.consumer, 'kimi', unitId, failures);
842
+ _checkRequired(action, 'expected.consumer', action.expected?.consumer, platform.id, unitId, failures);
934
843
  _checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
935
844
  _checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
936
845
  _checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);