release-skill 0.9.26 → 0.9.28

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 (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +56 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +16 -10
  11. package/README.zh-CN.md +16 -10
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/consumer-contract-vectors.json +7 -7
  14. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/release-skill.bundle.mjs +2631 -1678
  16. package/adapters/claude/skills/release-assess/SKILL.md +64 -1
  17. package/adapters/claude/skills/release-finish/SKILL.md +2 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +2 -2
  19. package/adapters/claude/skills/release-setup/SKILL.md +5 -2
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/consumer-contract-vectors.json +7 -7
  22. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  23. package/adapters/codex/bin/release-skill.bundle.mjs +2631 -1678
  24. package/adapters/codex/skills/release-assess/SKILL.md +64 -1
  25. package/adapters/codex/skills/release-finish/SKILL.md +2 -0
  26. package/adapters/codex/skills/release-help/SKILL.md +2 -2
  27. package/adapters/codex/skills/release-setup/SKILL.md +5 -2
  28. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  29. package/adapters/cursor/bin/consumer-contract-vectors.json +7 -7
  30. package/adapters/cursor/bin/foundation-resource-binding.json +1 -1
  31. package/adapters/cursor/bin/release-skill.bundle.mjs +2631 -1678
  32. package/adapters/cursor/skills/release-assess/SKILL.md +64 -1
  33. package/adapters/cursor/skills/release-finish/SKILL.md +2 -0
  34. package/adapters/cursor/skills/release-help/SKILL.md +2 -2
  35. package/adapters/cursor/skills/release-setup/SKILL.md +5 -2
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/consumer-contract-vectors.json +7 -7
  38. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  39. package/adapters/kimi/bin/release-skill.bundle.mjs +2631 -1678
  40. package/adapters/kimi/skills/release-assess/SKILL.md +64 -1
  41. package/adapters/kimi/skills/release-finish/SKILL.md +2 -0
  42. package/adapters/kimi/skills/release-help/SKILL.md +2 -2
  43. package/adapters/kimi/skills/release-setup/SKILL.md +5 -2
  44. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  45. package/adapters/qoder/bin/consumer-contract-vectors.json +7 -7
  46. package/adapters/qoder/bin/foundation-resource-binding.json +1 -1
  47. package/adapters/qoder/bin/release-skill.bundle.mjs +2631 -1678
  48. package/adapters/qoder/skills/release-assess/SKILL.md +64 -1
  49. package/adapters/qoder/skills/release-finish/SKILL.md +2 -0
  50. package/adapters/qoder/skills/release-help/SKILL.md +2 -2
  51. package/adapters/qoder/skills/release-setup/SKILL.md +5 -2
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/consumer-contract-vectors.json +7 -7
  54. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  55. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2631 -1678
  56. package/adapters/workbuddy/skills/release-assess/SKILL.md +64 -1
  57. package/adapters/workbuddy/skills/release-finish/SKILL.md +2 -0
  58. package/adapters/workbuddy/skills/release-help/SKILL.md +2 -2
  59. package/adapters/workbuddy/skills/release-setup/SKILL.md +5 -2
  60. package/bin/consumer-contract-vectors.json +7 -7
  61. package/bin/foundation-resource-binding.json +1 -1
  62. package/bin/release-skill-cli.mjs +45 -1
  63. package/bin/release-skill.bundle.mjs +2631 -1678
  64. package/package.json +4 -4
  65. package/platform-manifest.json +4 -4
  66. package/skills/release-assess/SKILL.md +64 -1
  67. package/skills/release-finish/SKILL.md +2 -0
  68. package/skills/release-help/SKILL.md +2 -2
  69. package/skills/release-setup/SKILL.md +5 -2
  70. package/skills-src/release-assess/SKILL.md +64 -1
  71. package/skills-src/release-finish/SKILL.md +2 -0
  72. package/skills-src/release-help/SKILL.md +2 -2
  73. package/skills-src/release-setup/SKILL.md +5 -2
  74. package/src/commands/post-release-local.mjs +36 -92
  75. package/src/commands/setup.mjs +149 -6
  76. package/src/core/adoption-assessment.mjs +502 -4
  77. package/src/core/local-host-observation.mjs +225 -0
  78. package/src/platforms/registry.mjs +12 -4
  79. package/src/producers/foundation-resource-projection.mjs +3 -3
@@ -32,11 +32,14 @@
32
32
  * timestamps, findings are sorted by (category, code, fieldPath, unitId),
33
33
  * and `assessmentDigest` binds the config digest plus the full discovery
34
34
  * facts so any fact change invalidates earlier suggestion summaries for any
35
- * future write path.
35
+ * future write path. When host-coverage diagnosis is requested, the digest
36
+ * also binds this invocation's complete JSON input, live observation records,
37
+ * and load-error fact.
36
38
  *
37
39
  * @module adoption-assessment
38
40
  */
39
41
 
42
+ import { getPlatform, normalizeHostId } from '../platforms/registry.mjs';
40
43
  import { canonicalJson, sha256Hex } from './digest.mjs';
41
44
  import { resolveProducerVersion } from './evidence.mjs';
42
45
 
@@ -290,6 +293,482 @@ export function deriveStatus(findings) {
290
293
  return ASSESSMENT_STATUS.ADOPTED;
291
294
  }
292
295
 
296
+ const HOST_COVERAGE_OBSERVATION_STATES = Object.freeze(['installed', 'not-installed', 'unknown']);
297
+
298
+ export function emptyHostCoverage() {
299
+ return Object.freeze({
300
+ requested: false,
301
+ complete: false,
302
+ results: Object.freeze([]),
303
+ findings: Object.freeze([]),
304
+ binding: null,
305
+ });
306
+ }
307
+
308
+ export function publicHostCoverage(coverage = emptyHostCoverage()) {
309
+ return Object.freeze({
310
+ requested: coverage.requested === true,
311
+ complete: coverage.complete === true,
312
+ results: coverage.results ?? Object.freeze([]),
313
+ });
314
+ }
315
+
316
+ function platformIdOrNull(value) {
317
+ try {
318
+ return getPlatform(value).id;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ async function canonicalizeHost(value) {
325
+ if (typeof value !== 'string' || value.trim().length === 0) {
326
+ return { ok: false, raw: value, reason: 'host id is missing' };
327
+ }
328
+ const trimmed = value.trim();
329
+ let foundationId = null;
330
+ try {
331
+ foundationId = await normalizeHostId(trimmed);
332
+ } catch (error) {
333
+ const platformId = platformIdOrNull(trimmed);
334
+ if (platformId) return { ok: true, raw: value, host: platformId };
335
+ return {
336
+ ok: false,
337
+ raw: value,
338
+ reason: error?.message ?? 'host id could not be normalized',
339
+ };
340
+ }
341
+ const foundationPlatform = platformIdOrNull(foundationId);
342
+ if (foundationPlatform) return { ok: true, raw: value, host: foundationPlatform };
343
+ const rawPlatform = platformIdOrNull(trimmed);
344
+ if (rawPlatform) return { ok: true, raw: value, host: rawPlatform };
345
+ return { ok: true, raw: value, host: foundationId };
346
+ }
347
+
348
+ function uniquePush(list, value) {
349
+ if (!list.includes(value)) list.push(value);
350
+ }
351
+
352
+ function applicableDistribution(unit, host) {
353
+ let distributionType = null;
354
+ try {
355
+ distributionType = getPlatform(host).distributionType;
356
+ } catch {
357
+ distributionType = null;
358
+ }
359
+ if (distributionType == null) return null;
360
+ return (unit?.distributions ?? []).find((item) => item.type === distributionType) ?? null;
361
+ }
362
+
363
+ function nonemptyString(value) {
364
+ return typeof value === 'string' && value.length > 0 ? value : null;
365
+ }
366
+
367
+ function declaredPluginIdentity(unit, host) {
368
+ const localPlugin = nonemptyString(unit?.postPublish?.localHostUpdate?.plugin);
369
+ if (localPlugin) return localPlugin;
370
+ return nonemptyString(applicableDistribution(unit, host)?.plugin);
371
+ }
372
+
373
+ function declaredMarketplaceIdentity(unit, host) {
374
+ const localMarketplace = nonemptyString(unit?.postPublish?.localHostUpdate?.hub?.name);
375
+ if (localMarketplace) return localMarketplace;
376
+ return nonemptyString(applicableDistribution(unit, host)?.marketplace);
377
+ }
378
+
379
+ function wiringFacts(config, unitId, host) {
380
+ if (!config) return { available: false };
381
+ const unit = (config.releaseUnits ?? []).find((item) => item.id === unitId);
382
+ if (!unit) return { available: false, unitFound: false };
383
+ let distributionType = null;
384
+ try {
385
+ distributionType = getPlatform(host).distributionType;
386
+ } catch {
387
+ distributionType = null;
388
+ }
389
+ const hasLocalUpdate = Array.isArray(unit.postPublish?.localHostUpdate?.hosts)
390
+ && unit.postPublish.localHostUpdate.hosts.includes(host);
391
+ if (distributionType == null) {
392
+ return { available: false, unitFound: true, hasLocalUpdate };
393
+ }
394
+ const hasDistribution = (unit.distributions ?? []).some((item) => item.type === distributionType);
395
+ return {
396
+ available: true,
397
+ unitFound: true,
398
+ hasDistribution,
399
+ hasLocalUpdate,
400
+ distributionType,
401
+ };
402
+ }
403
+
404
+ function observationKey(unitId, host) {
405
+ return `${unitId}\u0000${host}`;
406
+ }
407
+
408
+ function validObservedAt(value) {
409
+ const observedAt = nonemptyString(value);
410
+ if (observedAt == null || Number.isNaN(Date.parse(observedAt))) return null;
411
+ return observedAt;
412
+ }
413
+
414
+ function normalizeObservationRecord(observation, host) {
415
+ return {
416
+ host,
417
+ plugin: nonemptyString(observation?.plugin),
418
+ marketplace: nonemptyString(observation?.marketplace),
419
+ state: HOST_COVERAGE_OBSERVATION_STATES.includes(observation?.state) ? observation.state : null,
420
+ source: nonemptyString(observation?.source),
421
+ observedAt: validObservedAt(observation?.observedAt),
422
+ version: nonemptyString(observation?.version),
423
+ };
424
+ }
425
+
426
+ function observationFactsIssue(observation) {
427
+ if (!observation || observation.state == null) return 'install observation is missing';
428
+ if (observation.plugin == null) return 'observation plugin is missing';
429
+ if (observation.marketplace == null) return 'observation marketplace is missing';
430
+ if (observation.source == null) return 'observation source is missing';
431
+ if (observation.observedAt == null) return 'observation observedAt is missing or not a valid timestamp';
432
+ if (observation.state === 'installed' && observation.version == null) {
433
+ return 'installed observation version is missing';
434
+ }
435
+ return null;
436
+ }
437
+
438
+ function mergeObservation(fileObservation, liveObservation) {
439
+ if (!fileObservation && !liveObservation) return null;
440
+ if (!liveObservation) return fileObservation;
441
+ if (!fileObservation) return liveObservation;
442
+ const identityConflict = (
443
+ (fileObservation.plugin && liveObservation.plugin && fileObservation.plugin !== liveObservation.plugin)
444
+ || (fileObservation.marketplace && liveObservation.marketplace && fileObservation.marketplace !== liveObservation.marketplace)
445
+ );
446
+ if (identityConflict) {
447
+ return {
448
+ ...liveObservation,
449
+ state: 'unknown',
450
+ reason: 'file and live observations disagree on plugin identity',
451
+ };
452
+ }
453
+ if (
454
+ fileObservation.state
455
+ && fileObservation.state !== 'unknown'
456
+ && liveObservation.state
457
+ && liveObservation.state !== 'unknown'
458
+ && fileObservation.state !== liveObservation.state
459
+ ) {
460
+ return {
461
+ ...liveObservation,
462
+ state: 'unknown',
463
+ reason: 'file and live observations disagree on install state',
464
+ };
465
+ }
466
+ return liveObservation;
467
+ }
468
+
469
+ /**
470
+ * Compare explicit host-coverage input with project wiring and observations.
471
+ * Support facts come only from the input declaration, never from distributions
472
+ * or localHostUpdate.
473
+ *
474
+ * @param {object} options
475
+ * @returns {Promise<object>}
476
+ */
477
+ export async function evaluateHostCoverage({
478
+ requested = false,
479
+ input = null,
480
+ loadError = null,
481
+ config = null,
482
+ liveObservations = [],
483
+ } = {}) {
484
+ if (!requested) return emptyHostCoverage();
485
+
486
+ const issues = [];
487
+ const results = [];
488
+ const binding = { input, liveObservations, loadError };
489
+ if (loadError) issues.push(loadError);
490
+ if (input == null) {
491
+ return finishHostCoverage({ requested: true, complete: false, results, issues, binding });
492
+ }
493
+ if (input.schemaVersion !== 1) issues.push('schemaVersion must be 1');
494
+ if (!Array.isArray(input.units)) {
495
+ issues.push('units must be an array');
496
+ return finishHostCoverage({
497
+ requested: true,
498
+ complete: false,
499
+ results,
500
+ issues,
501
+ binding,
502
+ });
503
+ }
504
+
505
+ const seenUnitIds = new Set();
506
+ for (const unit of input.units) {
507
+ const unitId = typeof unit?.unitId === 'string' ? unit.unitId : null;
508
+ if (unitId == null || unitId.length === 0) {
509
+ issues.push('unitId is missing');
510
+ continue;
511
+ }
512
+ const duplicatedUnit = seenUnitIds.has(unitId);
513
+ if (duplicatedUnit) {
514
+ issues.push(`unitId "${unitId}" is duplicated`);
515
+ } else {
516
+ seenUnitIds.add(unitId);
517
+ }
518
+
519
+ const supportSource = typeof unit?.support?.source === 'string' ? unit.support.source.trim() : '';
520
+ if (supportSource.length === 0) issues.push(`unit "${unitId}" is missing support.source`);
521
+
522
+ const supportHosts = [];
523
+ for (const raw of Array.isArray(unit?.support?.hosts) ? unit.support.hosts : []) {
524
+ const canonical = await canonicalizeHost(raw);
525
+ if (!canonical.ok) {
526
+ issues.push(`unit "${unitId}" has unresolvable support host ${JSON.stringify(raw)}`);
527
+ if (typeof raw === 'string' && raw.length > 0) uniquePush(supportHosts, raw);
528
+ continue;
529
+ }
530
+ uniquePush(supportHosts, canonical.host);
531
+ }
532
+ if (!Array.isArray(unit?.support?.hosts)) issues.push(`unit "${unitId}" is missing support.hosts`);
533
+
534
+ const includeHosts = [];
535
+ if (!Array.isArray(unit?.delivery?.include)) {
536
+ issues.push(`unit "${unitId}" is missing delivery.include`);
537
+ } else {
538
+ for (const raw of unit.delivery.include) {
539
+ const canonical = await canonicalizeHost(raw);
540
+ if (!canonical.ok) {
541
+ issues.push(`unit "${unitId}" has unresolvable include host ${JSON.stringify(raw)}`);
542
+ if (typeof raw === 'string' && raw.length > 0) uniquePush(includeHosts, raw);
543
+ continue;
544
+ }
545
+ uniquePush(includeHosts, canonical.host);
546
+ }
547
+ }
548
+
549
+ const excludeByHost = new Map();
550
+ if (!Array.isArray(unit?.delivery?.exclude)) {
551
+ issues.push(`unit "${unitId}" is missing delivery.exclude`);
552
+ } else {
553
+ for (const entry of unit.delivery.exclude) {
554
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.reason !== 'string' || entry.reason.length === 0) {
555
+ issues.push(`unit "${unitId}" exclude entries must be {host, reason} objects`);
556
+ continue;
557
+ }
558
+ const canonical = await canonicalizeHost(entry.host);
559
+ if (!canonical.ok) {
560
+ issues.push(`unit "${unitId}" has unresolvable exclude host ${JSON.stringify(entry.host)}`);
561
+ continue;
562
+ }
563
+ if (excludeByHost.has(canonical.host)) {
564
+ issues.push(`unit "${unitId}" host "${canonical.host}" is excluded more than once`);
565
+ continue;
566
+ }
567
+ excludeByHost.set(canonical.host, entry.reason);
568
+ }
569
+ }
570
+
571
+ const installIfMissing = [];
572
+ if (!Array.isArray(unit?.delivery?.installIfMissing)) {
573
+ issues.push(`unit "${unitId}" is missing delivery.installIfMissing`);
574
+ } else {
575
+ for (const raw of unit.delivery.installIfMissing) {
576
+ const canonical = await canonicalizeHost(raw);
577
+ if (!canonical.ok) {
578
+ issues.push(`unit "${unitId}" has unresolvable installIfMissing host ${JSON.stringify(raw)}`);
579
+ continue;
580
+ }
581
+ uniquePush(installIfMissing, canonical.host);
582
+ }
583
+ }
584
+
585
+ const fileObservations = new Map();
586
+ const duplicateHosts = new Set();
587
+ if (!Array.isArray(unit?.observations)) {
588
+ issues.push(`unit "${unitId}" is missing observations`);
589
+ } else {
590
+ for (const observation of unit.observations) {
591
+ const canonical = await canonicalizeHost(observation?.host);
592
+ if (!canonical.ok) {
593
+ issues.push(`unit "${unitId}" has unresolvable observation host ${JSON.stringify(observation?.host)}`);
594
+ continue;
595
+ }
596
+ const key = observationKey(unitId, canonical.host);
597
+ if (fileObservations.has(key) || duplicateHosts.has(canonical.host)) {
598
+ duplicateHosts.add(canonical.host);
599
+ fileObservations.delete(key);
600
+ issues.push(`unit "${unitId}" has duplicate observations for host "${canonical.host}"`);
601
+ continue;
602
+ }
603
+ const normalized = normalizeObservationRecord(observation, canonical.host);
604
+ if (normalized.state == null) {
605
+ issues.push(`unit "${unitId}" observation for "${canonical.host}" has invalid state`);
606
+ }
607
+ fileObservations.set(key, normalized);
608
+ }
609
+ }
610
+
611
+ const liveByHost = new Map();
612
+ for (const observation of liveObservations) {
613
+ if (observation?.unitId !== unitId) continue;
614
+ const canonical = await canonicalizeHost(observation.host);
615
+ if (!canonical.ok) continue;
616
+ liveByHost.set(canonical.host, {
617
+ ...normalizeObservationRecord(observation, canonical.host),
618
+ reason: observation.reason,
619
+ });
620
+ }
621
+
622
+ const declaredHosts = [];
623
+ for (const host of [...supportHosts, ...includeHosts, ...excludeByHost.keys(), ...installIfMissing]) {
624
+ uniquePush(declaredHosts, host);
625
+ }
626
+ for (const observation of [...fileObservations.values(), ...liveByHost.values()]) {
627
+ uniquePush(declaredHosts, observation.host);
628
+ }
629
+ declaredHosts.sort((a, b) => a.localeCompare(b));
630
+
631
+ const unitConfig = (config?.releaseUnits ?? []).find((item) => item.id === unitId) ?? null;
632
+ if (config != null && unitConfig == null) {
633
+ issues.push(`requested unit "${unitId}" is not declared in config.releaseUnits`);
634
+ }
635
+
636
+ for (const host of declaredHosts) {
637
+ const excluded = excludeByHost.has(host);
638
+ const included = includeHosts.includes(host);
639
+ const supported = supportHosts.includes(host);
640
+ const firstInstall = installIfMissing.includes(host);
641
+ const fileObservation = fileObservations.get(observationKey(unitId, host));
642
+ const liveObservation = liveByHost.get(host);
643
+ const observation = mergeObservation(fileObservation, liveObservation);
644
+ const duplicate = duplicateHosts.has(host);
645
+ const wiring = wiringFacts(config, unitId, host);
646
+ const expectedPlugin = declaredPluginIdentity(unitConfig, host);
647
+ const expectedMarketplace = declaredMarketplaceIdentity(unitConfig, host);
648
+ const identityConflict = Boolean(
649
+ (observation?.plugin && expectedPlugin && observation.plugin !== expectedPlugin)
650
+ || (observation?.marketplace && expectedMarketplace && observation.marketplace !== expectedMarketplace),
651
+ );
652
+ const factsIssue = observationFactsIssue(observation);
653
+
654
+ let status = 'unknown';
655
+ let reason = 'host coverage facts are incomplete';
656
+ if (duplicate) {
657
+ reason = 'duplicate observations for the same host';
658
+ } else if (config != null && unitConfig == null) {
659
+ reason = 'requested unit is not declared in config.releaseUnits';
660
+ } else if (excluded && included) {
661
+ reason = 'host is both included and excluded';
662
+ issues.push(`unit "${unitId}" host "${host}" is both included and excluded`);
663
+ } else if (excluded) {
664
+ status = FINDING_CATEGORY.NOT_APPLICABLE;
665
+ reason = `已排除:${excludeByHost.get(host)}`;
666
+ } else if (supportSource.length === 0) {
667
+ reason = 'support.source is missing';
668
+ } else if (!supported) {
669
+ reason = 'host is not declared in support.hosts; support cannot be inferred from wiring';
670
+ } else if (!included) {
671
+ reason = 'supported host is not included and has no exclusion reason';
672
+ } else if (identityConflict) {
673
+ reason = 'observation identity conflicts with the unit distribution or local-update identity';
674
+ } else if (factsIssue) {
675
+ reason = factsIssue;
676
+ } else if (observation.state === 'unknown') {
677
+ reason = observation.reason ?? 'install observation is unknown';
678
+ } else if (observation.state === 'not-installed' && !firstInstall) {
679
+ status = FINDING_CATEGORY.NOT_APPLICABLE;
680
+ reason = 'host is not installed and this round has no first-install duty';
681
+ } else if (!wiring.available) {
682
+ reason = 'project wiring facts are unavailable';
683
+ } else if (!wiring.hasDistribution || !wiring.hasLocalUpdate) {
684
+ status = FINDING_CATEGORY.MANDATORY_GAP;
685
+ const missing = [
686
+ wiring.hasDistribution ? null : 'applicable distribution',
687
+ wiring.hasLocalUpdate ? null : 'localHostUpdate host wiring',
688
+ ].filter(Boolean);
689
+ reason = `supported in-scope host is missing ${missing.join(' and ')}`;
690
+ } else {
691
+ status = FINDING_CATEGORY.SATISFIED;
692
+ reason = observation.state === 'not-installed'
693
+ ? 'first-install duty has complete wiring'
694
+ : 'supported in-scope host is installed and wired';
695
+ }
696
+
697
+ if (!duplicatedUnit && status === 'unknown') {
698
+ issues.push(`unit "${unitId}" host "${host}": ${reason}`);
699
+ }
700
+
701
+ const evidence = {
702
+ support: { hosts: [...supportHosts], source: supportSource || null },
703
+ delivery: {
704
+ include: [...includeHosts],
705
+ exclude: [...excludeByHost.entries()].map(([excludeHost, excludeReason]) => ({
706
+ host: excludeHost,
707
+ reason: excludeReason,
708
+ })),
709
+ installIfMissing: [...installIfMissing],
710
+ },
711
+ observation: observation ?? null,
712
+ wiring,
713
+ };
714
+ if (!duplicatedUnit) {
715
+ results.push(Object.freeze({
716
+ unitId,
717
+ host,
718
+ status,
719
+ reason,
720
+ evidence,
721
+ }));
722
+ }
723
+ }
724
+ }
725
+
726
+ results.sort((a, b) => a.unitId.localeCompare(b.unitId) || a.host.localeCompare(b.host));
727
+ const complete = issues.length === 0 && results.every((item) => item.status !== 'unknown');
728
+ return finishHostCoverage({
729
+ requested: true,
730
+ complete,
731
+ results,
732
+ issues,
733
+ binding,
734
+ });
735
+ }
736
+
737
+ function finishHostCoverage({ requested, complete, results, issues, binding }) {
738
+ const findings = [];
739
+ if (requested && !complete) {
740
+ findings.push(createFinding({
741
+ category: FINDING_CATEGORY.MANDATORY_GAP,
742
+ code: 'HOST_COVERAGE_FACTS_MISSING',
743
+ fieldPath: 'hostCoverage',
744
+ message: `宿主覆盖诊断已请求,但完成本次检查所需事实缺失:${issues[0] ?? 'unknown facts remain'}`,
745
+ evidence: { issues, complete: false },
746
+ action: '补齐支持出处、范围、观察或接线事实后重新评估;未知不得写成未安装或已通过。',
747
+ severity: 'blocking',
748
+ }));
749
+ }
750
+ for (const result of results) {
751
+ if (result.status !== FINDING_CATEGORY.MANDATORY_GAP) continue;
752
+ findings.push(createFinding({
753
+ category: FINDING_CATEGORY.MANDATORY_GAP,
754
+ code: 'HOST_COVERAGE_WIRING_GAP',
755
+ unitId: result.unitId,
756
+ fieldPath: `hostCoverage.${result.unitId}.${result.host}`,
757
+ message: `发布单元 "${result.unitId}" 宿主 "${result.host}" 覆盖不足:${result.reason}`,
758
+ evidence: result.evidence,
759
+ action: '补齐适用分发或 localHostUpdate 宿主接线后重新评估;安装观察不能证明加载成功。',
760
+ severity: 'blocking',
761
+ }));
762
+ }
763
+ return Object.freeze({
764
+ requested,
765
+ complete,
766
+ results: Object.freeze(results),
767
+ findings: Object.freeze(findings),
768
+ binding,
769
+ });
770
+ }
771
+
293
772
  /**
294
773
  * Derive gate suggestions from setup's gate candidates (scenarios 5 & 6).
295
774
  *
@@ -588,6 +1067,7 @@ const STATUS_LABEL = Object.freeze({
588
1067
  * @param {Array<Object>} options.gateDiagnostics - Non-actionable discovered
589
1068
  * scripts retained for explicit diagnosis without affecting status/summary.
590
1069
  * @param {string} [options.next] - Precise next step.
1070
+ * @param {Object} [options.hostCoverage] - Host-coverage diagnosis result.
591
1071
  * @returns {Object} Frozen report.
592
1072
  */
593
1073
  export function buildAssessmentReport({
@@ -600,6 +1080,7 @@ export function buildAssessmentReport({
600
1080
  gateSuggestions,
601
1081
  gateDiagnostics = [],
602
1082
  next = null,
1083
+ hostCoverage = emptyHostCoverage(),
603
1084
  }) {
604
1085
  const sortedFindings = [...findings].sort((a, b) => (
605
1086
  (CATEGORY_RANK[a.category] ?? 9) - (CATEGORY_RANK[b.category] ?? 9)
@@ -642,13 +1123,17 @@ export function buildAssessmentReport({
642
1123
  note: '评估默认离线,未观测远端;不能据此否定本地接入,也不代表具备生产发布条件。',
643
1124
  }];
644
1125
 
1126
+ const digestPayload = hostCoverage.requested
1127
+ ? { configDigest, discoveryFacts: facts, hostCoverage: hostCoverage.binding }
1128
+ : { configDigest, discoveryFacts: facts };
1129
+ const publicCoverage = publicHostCoverage(hostCoverage);
645
1130
  const report = {
646
1131
  command: 'release-setup',
647
1132
  mode: 'adoption-assessment',
648
1133
  status,
649
1134
  configPath,
650
1135
  configDigest,
651
- assessmentDigest: sha256Hex(canonicalJson({ configDigest, discoveryFacts: facts })),
1136
+ assessmentDigest: sha256Hex(canonicalJson(digestPayload)),
652
1137
  topology,
653
1138
  findings: sortedFindings,
654
1139
  hookDurations,
@@ -656,8 +1141,18 @@ export function buildAssessmentReport({
656
1141
  gateDiagnostics,
657
1142
  workflowPrerequisites,
658
1143
  unobserved,
1144
+ hostCoverage: publicCoverage,
659
1145
  next,
660
- summary: renderSummary({ config, status, findings: sortedFindings, topology, hookDurations, gateSuggestions, next }),
1146
+ summary: renderSummary({
1147
+ config,
1148
+ status,
1149
+ findings: sortedFindings,
1150
+ topology,
1151
+ hookDurations,
1152
+ gateSuggestions,
1153
+ next,
1154
+ hostCoverage: publicCoverage,
1155
+ }),
661
1156
  };
662
1157
  return Object.freeze(report);
663
1158
  }
@@ -668,13 +1163,16 @@ export function buildAssessmentReport({
668
1163
  * @param {Object} input
669
1164
  * @returns {string}
670
1165
  */
671
- function renderSummary({ config, status, findings, topology, gateSuggestions, next }) {
1166
+ function renderSummary({ config, status, findings, topology, gateSuggestions, next, hostCoverage }) {
672
1167
  const lines = [];
673
1168
  lines.push(`项目: ${config.project?.name ?? '(未命名)'}`);
674
1169
  lines.push(`拓扑: ${topology.type},发布单元 ${topology.releaseUnits.length} 个${topology.distributions.length > 0 ? `,分发渠道 ${topology.distributions.join(', ')}` : ''}`);
675
1170
  lines.push(`接入状态: ${STATUS_LABEL[status] ?? status}`);
676
1171
  const count = (category) => findings.filter((f) => f.category === category).length;
677
1172
  lines.push(`必选缺口 ${count('mandatory-gap')} | 已满足 ${count('satisfied')} | 可选建议 ${count('optional-suggestion')} | 不适用 ${count('not-applicable')}`);
1173
+ if (hostCoverage?.requested) {
1174
+ lines.push(`宿主覆盖: 已请求,${hostCoverage.complete ? '事实完整' : '事实不完整'},${hostCoverage.results.length} 项结果`);
1175
+ }
678
1176
  const blocking = findings.filter((f) => f.category === FINDING_CATEGORY.MANDATORY_GAP);
679
1177
  if (blocking.length > 0) {
680
1178
  lines.push('阻断项:');