packet-tracer-skill 0.2.0 → 0.2.2

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 (57) hide show
  1. package/CHANGELOG.md +42 -4
  2. package/README.md +237 -88
  3. package/docs/campus-donor-proof.md +103 -0
  4. package/docs/curated-donor-registry.md +26 -0
  5. package/docs/github-launch-ops-0.2.1.md +45 -0
  6. package/docs/github-launch-ops-0.2.2.md +42 -0
  7. package/docs/github-metadata.md +23 -6
  8. package/docs/hero-demo-plan.md +18 -10
  9. package/docs/home-iot-donor-proof.md +64 -0
  10. package/docs/launch-announcement-0.2.1.md +15 -0
  11. package/docs/launch-announcement-0.2.2.md +15 -0
  12. package/docs/packet-tracer-feature-gap-atlas.md +87 -0
  13. package/docs/post-launch-follow-up.md +39 -0
  14. package/docs/publish-preview-roadmap.md +20 -17
  15. package/docs/release-checklist.md +22 -12
  16. package/docs/{release-notes-0.2.0.md → release-notes-0.2.1.md} +7 -6
  17. package/docs/release-notes-0.2.2.md +26 -0
  18. package/docs/runtime-truth.md +31 -0
  19. package/docs/wan-security-donor-proof.md +41 -0
  20. package/docs/wireless-advanced-proof.md +60 -0
  21. package/examples/README.md +13 -10
  22. package/examples/gallery.md +10 -4
  23. package/examples/index.json +3 -3
  24. package/package.json +44 -16
  25. package/references/curated-donor-registry.json +5 -2
  26. package/references/packettracer-feature-atlas.json +133 -0
  27. package/references/scenario-fixture-corpus.json +1 -0
  28. package/scripts/build_examples_index.py +27 -16
  29. package/scripts/coverage_matrix.py +559 -23
  30. package/scripts/feature_atlas.py +229 -0
  31. package/scripts/generate_pkt.py +330 -31
  32. package/scripts/intent_parser.py +278 -7
  33. package/scripts/pkt_editor.py +174 -0
  34. package/scripts/runtime_doctor.py +83 -10
  35. package/scripts/sample_catalog.py +210 -5
  36. package/docs/screenshots/.gitkeep +0 -1
  37. package/docs/screenshots/packet-tracer-topology-cropped.png +0 -0
  38. package/scripts/__pycache__/build_examples_index.cpython-314.pyc +0 -0
  39. package/scripts/__pycache__/build_sample_catalog.cpython-314.pyc +0 -0
  40. package/scripts/__pycache__/coverage_matrix.cpython-314.pyc +0 -0
  41. package/scripts/__pycache__/donor_diagnostics.cpython-314.pyc +0 -0
  42. package/scripts/__pycache__/generate_pkt.cpython-314.pyc +0 -0
  43. package/scripts/__pycache__/install_skill.cpython-314.pyc +0 -0
  44. package/scripts/__pycache__/intent_parser.cpython-314.pyc +0 -0
  45. package/scripts/__pycache__/packet_tracer_env.cpython-314.pyc +0 -0
  46. package/scripts/__pycache__/pkt_builder.cpython-314.pyc +0 -0
  47. package/scripts/__pycache__/pkt_codec.cpython-314.pyc +0 -0
  48. package/scripts/__pycache__/pkt_editor.cpython-314.pyc +0 -0
  49. package/scripts/__pycache__/pkt_transformer.cpython-314.pyc +0 -0
  50. package/scripts/__pycache__/remote_search.cpython-314.pyc +0 -0
  51. package/scripts/__pycache__/runtime_doctor.cpython-314.pyc +0 -0
  52. package/scripts/__pycache__/sample_catalog.cpython-314.pyc +0 -0
  53. package/scripts/__pycache__/sample_selector.cpython-314.pyc +0 -0
  54. package/scripts/__pycache__/twofish_diagnostics.cpython-314.pyc +0 -0
  55. package/scripts/__pycache__/twofish_runtime.cpython-314.pyc +0 -0
  56. package/scripts/__pycache__/workspace_repair.cpython-314.pyc +0 -0
  57. package/scripts/vendor/__pycache__/twofish.cpython-314.pyc +0 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import copy
7
7
  from dataclasses import asdict, dataclass, field
8
+ from functools import lru_cache
8
9
  import json
9
10
  import os
10
11
  from pathlib import Path
@@ -25,6 +26,7 @@ from coverage_matrix import (
25
26
  build_inventory_capability_report,
26
27
  select_capability_matrix_hits,
27
28
  )
29
+ from feature_atlas import build_feature_gap_report
28
30
  from intent_parser import IntentPlan, parse_intent
29
31
  from packet_tracer_env import (
30
32
  get_packet_tracer_compatibility_donor,
@@ -107,6 +109,11 @@ if hasattr(sys.stdout, "reconfigure"):
107
109
  pass
108
110
 
109
111
 
112
+ @lru_cache(maxsize=1)
113
+ def _inspect_packet_tracer_compatibility_donor_cached():
114
+ return inspect_packet_tracer_compatibility_donor()
115
+
116
+
110
117
  class PlanningError(RuntimeError):
111
118
  def __init__(self, message: str, plan: IntentPlan) -> None:
112
119
  super().__init__(message)
@@ -139,7 +146,7 @@ STRICT_COMPATIBILITY_GAP = (
139
146
 
140
147
 
141
148
  def _compat_donor_details() -> tuple[Path | None, str | None]:
142
- details = inspect_packet_tracer_compatibility_donor()
149
+ details = _inspect_packet_tracer_compatibility_donor_cached()
143
150
  return details.resolved_path, details.donor_version
144
151
 
145
152
 
@@ -327,28 +334,97 @@ def _preferred_donor_archetypes_for_plan(plan: IntentPlan, topology_tags: list[s
327
334
  }
328
335
  capabilities = set(plan.capabilities)
329
336
  tags = set(topology_tags or [])
330
- if plan.department_groups or tags & {"chain", "core_access", "router_on_a_stick", "acl_policy"}:
331
- preferred.append("campus/core")
332
- elif device_families & {"routers", "switches", "multilayer switches"} and capabilities & {
333
- "vlan",
334
- "router_on_a_stick",
335
- "management_vlan",
336
- "telnet",
337
- }:
338
- preferred.append("campus/core")
339
- if requested_services & {"dhcp", "dns", "http", "https", "ftp", "tftp", "email", "syslog", "aaa", "ntp"} or "servers" in device_families:
340
- preferred.append("service-heavy")
341
- if plan.wireless_mode or capabilities & {
337
+ prompt_lower = str(plan.prompt or "").lower()
338
+ explicit_home_gateway_prompt = any(
339
+ token in prompt_lower
340
+ for token in ["home gateway", "homegateway", "smart home", "iot "]
341
+ )
342
+ family_hints: list[str] = []
343
+ campus_signal = (
344
+ plan.network_style == "campus"
345
+ or "campus" in prompt_lower
346
+ or "kampus" in prompt_lower
347
+ or bool(plan.department_groups)
348
+ or bool(tags & {"chain", "core_access", "router_on_a_stick", "acl_policy"})
349
+ or bool(
350
+ device_families & {"routers", "switches", "multilayer switches"}
351
+ and capabilities & {"vlan", "router_on_a_stick", "management_vlan", "telnet", "acl", "trunk", "access_port"}
352
+ )
353
+ )
354
+ home_iot_signal = (
355
+ plan.network_style in {"home_iot", "smart_home"}
356
+ or explicit_home_gateway_prompt
357
+ or plan.wireless_mode == "home_router_edge"
358
+ or bool(capabilities & {"iot", "iot_registration", "iot_control"})
359
+ or bool("iot devices" in device_families)
360
+ )
361
+ wan_signal = (
362
+ plan.network_style == "wan_security"
363
+ or bool(device_families & {"wan/cloud/dsl/cable devices", "security devices"})
364
+ or bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "multilayer_switching", "security_edge"})
365
+ )
366
+ ipv6_routing_signal = plan.network_style == "ipv6_routing" or bool(capabilities & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"})
367
+ l2_security_monitoring_signal = plan.network_style == "l2_security_monitoring" or bool(capabilities & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"})
368
+ wireless_advanced_signal = plan.network_style == "wireless_advanced" or bool(capabilities & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"})
369
+ automation_controller_signal = plan.network_style == "automation_controller" or bool(capabilities & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"})
370
+ voice_collaboration_signal = plan.network_style == "voice_collaboration" or bool(capabilities & {"voip", "ip_phone", "call_manager", "linksys_voice"})
371
+ industrial_iot_signal = plan.network_style == "industrial_iot" or bool(capabilities & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"})
372
+ wireless_signal = plan.wireless_mode or capabilities & {
342
373
  "wireless_ap",
343
374
  "wireless_client",
344
375
  "wireless_mutation",
345
376
  "wireless_client_association",
346
- }:
347
- preferred.append("wireless-heavy")
348
- if capabilities & {"iot", "iot_registration", "iot_control"} or "iot devices" in device_families:
349
- preferred.append("IoT/home gateway")
350
- if device_families & {"wan/cloud/dsl/cable devices", "security devices"} or capabilities & {"vpn", "nat", "pat", "acl"}:
351
- preferred.append("WAN/security edge")
377
+ }
378
+ service_signal = (
379
+ requested_services & {"dns", "dhcp", "http", "https", "ftp", "tftp", "email", "syslog", "aaa", "ntp"}
380
+ or capabilities & {"server_dns", "server_dhcp", "server_http", "server_https", "server_ftp", "server_tftp", "server_email", "server_syslog", "server_aaa"}
381
+ or "servers" in device_families
382
+ )
383
+
384
+ if industrial_iot_signal:
385
+ family_hints.append("industrial IoT")
386
+ elif automation_controller_signal:
387
+ family_hints.append("automation/controller")
388
+ elif voice_collaboration_signal:
389
+ family_hints.append("voice/collaboration")
390
+ elif wireless_advanced_signal:
391
+ family_hints.append("advanced wireless")
392
+ elif l2_security_monitoring_signal:
393
+ family_hints.append("L2 security/monitoring")
394
+ elif ipv6_routing_signal:
395
+ family_hints.append("IPv6/routing")
396
+ elif wan_signal:
397
+ family_hints.append("WAN/security edge")
398
+ if campus_signal and not bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "security_edge"}):
399
+ family_hints.append("campus/core")
400
+ elif campus_signal:
401
+ family_hints.append("campus/core")
402
+ if wireless_signal:
403
+ family_hints.append("wireless-heavy")
404
+ if service_signal and requested_services & {"email", "syslog", "aaa", "http", "https", "ftp", "tftp"}:
405
+ family_hints.append("service-heavy")
406
+ elif home_iot_signal:
407
+ family_hints.append("IoT/home gateway")
408
+ if wireless_signal:
409
+ family_hints.append("wireless-heavy")
410
+ elif service_signal:
411
+ family_hints.append("service-heavy")
412
+ elif wireless_signal:
413
+ family_hints.append("wireless-heavy")
414
+
415
+ if not family_hints:
416
+ if service_signal:
417
+ family_hints.append("service-heavy")
418
+ if wireless_signal:
419
+ family_hints.append("wireless-heavy")
420
+ if home_iot_signal:
421
+ family_hints.append("IoT/home gateway")
422
+ if wan_signal:
423
+ family_hints.append("WAN/security edge")
424
+
425
+ for item in family_hints:
426
+ if item not in preferred:
427
+ preferred.append(item)
352
428
  return preferred
353
429
 
354
430
 
@@ -361,6 +437,16 @@ def _required_runtime_features_for_plan(plan: IntentPlan) -> list[str]:
361
437
  features.add("wireless_runtime")
362
438
  if capabilities & {"iot", "iot_registration", "iot_control"}:
363
439
  features.add("iot_runtime")
440
+ if capabilities & {"vpn", "ipsec", "gre"}:
441
+ features.add("tunnel_runtime")
442
+ if capabilities & {"ppp"}:
443
+ features.add("wan_runtime")
444
+ if capabilities & {"security_edge", "acl", "nat"}:
445
+ features.add("security_runtime")
446
+ if capabilities & {"multilayer_switching"}:
447
+ features.add("multilayer_runtime")
448
+ if capabilities & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
449
+ features.add("ipv6_runtime")
364
450
  return sorted(features)
365
451
 
366
452
 
@@ -437,6 +523,20 @@ def _candidate_acceptance_penalty(candidate: SampleCandidate, blueprint: dict[st
437
523
  "wireless_mutation": 18,
438
524
  "wireless_client_association": 22,
439
525
  "end_device_mutation": 12,
526
+ "iot_registration": 26,
527
+ "iot_control": 24,
528
+ "vpn": 22,
529
+ "ipsec": 22,
530
+ "gre": 20,
531
+ "ppp": 18,
532
+ "security_edge": 20,
533
+ "multilayer_switching": 16,
534
+ "ipv6_slaac": 18,
535
+ "dhcpv6_stateful": 18,
536
+ "ospfv3": 18,
537
+ "eigrp_ipv6": 18,
538
+ "ripng": 16,
539
+ "hsrp": 16,
440
540
  }.items():
441
541
  if capability in requested_capabilities and capability not in supported_capabilities:
442
542
  penalty += penalty_value
@@ -507,13 +607,108 @@ def _summarize_candidate_pool(
507
607
  break
508
608
  if len(top_rejection_reasons) >= 5:
509
609
  continue
610
+ primary_rejection_code = _primary_rejection_code(top_rejection_reasons)
611
+ primary_rejection_layer = _primary_rejection_layer(primary_rejection_code)
612
+ best_rejected_donor_class = (
613
+ next((str(item) for item in list(preferred_archetypes or []) if str(item).strip()), None)
614
+ if any(counts[key] > 0 for key in ("rejected", "filtered"))
615
+ else None
616
+ )
510
617
  return {
511
618
  "preferred_donor_archetypes": list(preferred_archetypes or []),
512
619
  "candidate_counts": counts,
513
620
  "best_adjusted_total_score": best_adjusted_score,
514
621
  "best_layout_reuse_score": best_layout_reuse_score,
515
622
  "top_rejection_reasons": top_rejection_reasons,
623
+ "best_rejected_donor_class": best_rejected_donor_class,
624
+ "primary_rejection_code": primary_rejection_code,
625
+ "primary_rejection_layer": primary_rejection_layer,
626
+ }
627
+
628
+
629
+ def _primary_rejection_code(rejection_reasons: list[str]) -> str | None:
630
+ normalized = [str(item).strip().lower() for item in rejection_reasons if str(item).strip()]
631
+ if any("runtime subtree" in reason for reason in normalized):
632
+ return "runtime_subtree_missing"
633
+ if any("archetype_gap:" in reason or "archetype does not align" in reason for reason in normalized):
634
+ return "archetype_misaligned"
635
+ if any(
636
+ marker in reason
637
+ for reason in normalized
638
+ for marker in (
639
+ "missing_link_pairs:",
640
+ "no reusable link pairs",
641
+ "reuses too little of the requested link skeleton",
642
+ "cannot create new donor link pair",
643
+ "requires donor link reuse",
644
+ "port mismatch",
645
+ "media mismatch",
646
+ "ports/media",
647
+ )
648
+ ):
649
+ return "layout_reuse_too_weak"
650
+ if any(
651
+ marker in reason
652
+ for reason in normalized
653
+ for marker in (
654
+ "acceptance penalty",
655
+ "acceptance_gated",
656
+ "acceptance-gated",
657
+ "acceptance fixture",
658
+ "acceptance evidence",
659
+ "acceptance risk",
660
+ )
661
+ ):
662
+ return "acceptance_evidence_too_weak"
663
+ return None
664
+
665
+
666
+ def _primary_rejection_layer(primary_rejection_code: str | None) -> str | None:
667
+ if primary_rejection_code == "acceptance_evidence_too_weak":
668
+ return "acceptance"
669
+ if primary_rejection_code in {
670
+ "layout_reuse_too_weak",
671
+ "archetype_misaligned",
672
+ "runtime_subtree_missing",
673
+ }:
674
+ return "donor"
675
+ return None
676
+
677
+
678
+ def _best_rejected_donor_summary(
679
+ best_rejected_donor_class: str | None,
680
+ primary_rejection_code: str | None,
681
+ top_rejection_reasons: list[str],
682
+ ) -> str | None:
683
+ if not best_rejected_donor_class and not primary_rejection_code:
684
+ return None
685
+ reason_sentence_map = {
686
+ "layout_reuse_too_weak": "its reusable link skeleton is still too weak for the requested topology",
687
+ "acceptance_evidence_too_weak": "its acceptance evidence is still too weak for prompt-level generate",
688
+ "archetype_misaligned": "its donor shape does not align with the requested scenario archetype",
689
+ "runtime_subtree_missing": "the required runtime subtree is missing from the donor path",
690
+ }
691
+ next_shape_map = {
692
+ "layout_reuse_too_weak": "choose a donor with the same family but a stronger reusable router-switch-service skeleton",
693
+ "acceptance_evidence_too_weak": "choose a donor with explicit acceptance-backed coverage for the same scenario family",
694
+ "archetype_misaligned": "choose a donor whose archetype already matches the requested family",
695
+ "runtime_subtree_missing": "choose a donor that already contains the required runtime subtree and open-state structure",
516
696
  }
697
+ donor_class = best_rejected_donor_class or "the closest donor class"
698
+ reason = reason_sentence_map.get(primary_rejection_code or "", "it still did not satisfy strict donor selection")
699
+ next_shape = next_shape_map.get(primary_rejection_code or "", "choose a closer donor before prompt generate")
700
+ if donor_class == "WAN/security edge":
701
+ wan_next_shape_map = {
702
+ "layout_reuse_too_weak": "choose a WAN/security donor with reusable ASA/cloud/serial or tunnel skeleton",
703
+ "acceptance_evidence_too_weak": "choose a WAN/security donor with explicit VPN/IPSec/GRE/PPP acceptance evidence",
704
+ "archetype_misaligned": "choose a donor already tagged as WAN/security edge",
705
+ "runtime_subtree_missing": "choose a donor that already contains the WAN/security runtime subtree",
706
+ }
707
+ next_shape = wan_next_shape_map.get(primary_rejection_code or "", "choose a closer WAN/security donor before prompt generate")
708
+ summary = f"Best rejected donor class {donor_class} was closest, but {reason}; {next_shape}."
709
+ if top_rejection_reasons:
710
+ summary = f"{summary} Top rejection signal: {top_rejection_reasons[0]}."
711
+ return summary
517
712
 
518
713
 
519
714
  def _load_fixture_corpus() -> dict[str, object]:
@@ -699,6 +894,10 @@ def _filter_candidates_for_blueprint(
699
894
  filter_reasons.append("sample lacks donor-backed support for requested wireless mutation")
700
895
  if "end_device_mutation" in required_capabilities and "end_device_mutation" not in supported_capabilities:
701
896
  filter_reasons.append("sample lacks donor-backed support for requested end-device mutation")
897
+ if "iot_registration" in required_capabilities and "iot_registration" not in supported_capabilities:
898
+ filter_reasons.append("sample lacks donor-backed support for requested IoT registration")
899
+ if "iot_control" in required_capabilities and "iot_control" not in supported_capabilities:
900
+ filter_reasons.append("sample lacks donor-backed support for requested IoT control")
702
901
  if adjusted_total_score <= 0:
703
902
  filter_reasons.append("acceptance penalty reduced the donor score below zero")
704
903
  if filter_reasons:
@@ -2472,8 +2671,11 @@ def _augment_coverage_gap_actions(
2472
2671
  actions.append("For service-heavy prompts, prefer a donor that already contains the required server service family and core server layout.")
2473
2672
  if scenario_family == "home_iot" and scenario_status in {"donor_limited", "acceptance_gated", "unsupported"}:
2474
2673
  actions.append("For home IoT prompts, prefer a donor with Home Gateway plus existing IoT registration/control structure.")
2674
+ actions.append("For donor-backed Home IoT generate, explicitly name the thing, gateway/server target, and wireless client or SSID targets.")
2475
2675
  if scenario_family == "wan_security_edge" and scenario_status in {"donor_limited", "acceptance_gated", "unsupported"}:
2476
2676
  actions.append("For WAN/security prompts, prefer a donor with reusable serial/WAN or security-edge skeleton before generating.")
2677
+ if scenario_family in {"ipv6_routing", "l2_security_monitoring", "wireless_advanced", "automation_controller", "voice_collaboration", "industrial_iot", "physical_media_device"} and scenario_status in {"donor_limited", "acceptance_gated", "unsupported"}:
2678
+ actions.append("This feature family is atlas/report-first; keep it out of strict generate until donor-backed proof and acceptance fixtures exist.")
2477
2679
  updated["recommended_next_actions"] = list(dict.fromkeys(actions))
2478
2680
  return updated
2479
2681
 
@@ -2524,12 +2726,26 @@ def _scenario_generate_decision(
2524
2726
  "service_heavy": "service-heavy",
2525
2727
  "home_iot": "home IoT",
2526
2728
  "wan_security_edge": "WAN/security edge",
2729
+ "ipv6_routing": "IPv6/routing",
2730
+ "l2_security_monitoring": "L2 security/monitoring",
2731
+ "wireless_advanced": "advanced wireless",
2732
+ "automation_controller": "automation/controller",
2733
+ "voice_collaboration": "voice/collaboration",
2734
+ "industrial_iot": "industrial IoT",
2735
+ "physical_media_device": "physical/media device",
2527
2736
  }
2528
2737
  expected_archetype_map = {
2529
2738
  "campus": "campus/core",
2530
2739
  "service_heavy": "service-heavy",
2531
2740
  "home_iot": "IoT/home gateway",
2532
2741
  "wan_security_edge": "WAN/security edge",
2742
+ "ipv6_routing": "IPv6/routing",
2743
+ "l2_security_monitoring": "L2 security/monitoring",
2744
+ "wireless_advanced": "advanced wireless",
2745
+ "automation_controller": "automation/controller",
2746
+ "voice_collaboration": "voice/collaboration",
2747
+ "industrial_iot": "industrial IoT",
2748
+ "physical_media_device": "physical/media device",
2533
2749
  }
2534
2750
  family_label = family_label_map.get(family, family)
2535
2751
  blocking_reasons: list[str] = []
@@ -2543,6 +2759,13 @@ def _scenario_generate_decision(
2543
2759
  expected_archetype = expected_archetype_map.get(family)
2544
2760
  sample_archetypes = [str(item) for item in list((selected_donor_summary or {}).get("sample_archetypes", [])) if str(item).strip()]
2545
2761
  donor_graph_summary = dict((selected_donor_summary or {}).get("donor_graph_summary") or {})
2762
+ best_rejected_donor_class = str((donor_selection_summary or {}).get("best_rejected_donor_class") or "").strip() or None
2763
+ primary_rejection_code = str((donor_selection_summary or {}).get("primary_rejection_code") or "").strip() or None
2764
+ best_rejected_donor_summary = _best_rejected_donor_summary(
2765
+ best_rejected_donor_class,
2766
+ primary_rejection_code,
2767
+ [str(item) for item in list((donor_selection_summary or {}).get("top_rejection_reasons", [])) if str(item).strip()],
2768
+ )
2546
2769
  if expected_archetype and sample_archetypes:
2547
2770
  aligned = expected_archetype in sample_archetypes
2548
2771
  decision["selected_donor_aligned"] = aligned
@@ -2590,6 +2813,8 @@ def _scenario_generate_decision(
2590
2813
  blocking_reasons.append(
2591
2814
  f"Scenario '{family_label}' depends on donor-limited safe-open coverage, but no compatible donor was selected."
2592
2815
  )
2816
+ if best_rejected_donor_summary:
2817
+ notes.append(best_rejected_donor_summary)
2593
2818
  layout_status = str(donor_graph_summary.get("layout_reuse_status") or "").strip()
2594
2819
  pair_coverage = donor_graph_summary.get("reusable_pair_coverage")
2595
2820
  if layout_status == "weak":
@@ -2600,6 +2825,18 @@ def _scenario_generate_decision(
2600
2825
  blocking_reasons.append(
2601
2826
  f"Scenario '{family_label}' donor selection has no reusable link-pair coverage for prompt generate."
2602
2827
  )
2828
+ if primary_rejection_code == "archetype_misaligned":
2829
+ blocking_reasons.append(
2830
+ f"Scenario '{family_label}' best donor class is still archetype-misaligned with the requested prompt shape."
2831
+ )
2832
+ elif primary_rejection_code == "runtime_subtree_missing":
2833
+ blocking_reasons.append(
2834
+ f"Scenario '{family_label}' donor candidates are missing required runtime subtree coverage for strict reuse."
2835
+ )
2836
+ elif primary_rejection_code == "acceptance_evidence_too_weak":
2837
+ blocking_reasons.append(
2838
+ f"Scenario '{family_label}' donor candidates still lack strong enough acceptance evidence for strict prompt generate."
2839
+ )
2603
2840
  if layout_status in {"strong", "partial"} and isinstance(pair_coverage, int) and pair_coverage > 0:
2604
2841
  notes.append(f"selected donor provides {pair_coverage}% reusable link-pair coverage ({layout_status})")
2605
2842
  if selected_donor_summary and not blocking_reasons:
@@ -2668,12 +2905,6 @@ def _scenario_acceptance_summary(
2668
2905
  donor_state = "candidate_pool_blocked"
2669
2906
  if donor_state == "not_selected" and int(candidate_counts.get("selected", 0) or 0) > 0:
2670
2907
  donor_state = "selection_pending"
2671
- selection_failure_type = None
2672
- if not selected_donor_summary:
2673
- if donor_state in {"candidate_pool_blocked", "selection_pending"}:
2674
- selection_failure_type = "viable_donor_found_but_acceptance_weak"
2675
- else:
2676
- selection_failure_type = "no_viable_donor_found"
2677
2908
  key_reasons = [str(item) for item in decision.get("blocking_reasons", []) if str(item).strip()]
2678
2909
  if not key_reasons:
2679
2910
  key_reasons = [str(item) for item in decision.get("notes", []) if str(item).strip()]
@@ -2682,11 +2913,19 @@ def _scenario_acceptance_summary(
2682
2913
  None,
2683
2914
  )
2684
2915
  top_rejection_reasons = [str(item) for item in list((donor_selection_summary or {}).get("top_rejection_reasons", [])) if str(item).strip()]
2916
+ primary_rejection_code = str((donor_selection_summary or {}).get("primary_rejection_code") or "").strip() or _primary_rejection_code(top_rejection_reasons)
2917
+ primary_rejection_layer = str((donor_selection_summary or {}).get("primary_rejection_layer") or "").strip() or _primary_rejection_layer(primary_rejection_code)
2918
+ best_rejected_donor_class = str((donor_selection_summary or {}).get("best_rejected_donor_class") or "").strip() or None
2919
+ selection_failure_type = None
2685
2920
  if not selected_donor_summary:
2686
- if any("archetype_gap:" in reason for reason in top_rejection_reasons):
2921
+ if primary_rejection_code == "archetype_misaligned":
2687
2922
  selection_failure_type = "viable_donor_found_but_archetype_misaligned"
2688
- elif any("runtime subtree" in reason.lower() for reason in top_rejection_reasons):
2923
+ elif primary_rejection_code == "runtime_subtree_missing":
2689
2924
  selection_failure_type = "viable_donor_found_but_runtime_subtree_missing"
2925
+ elif donor_state in {"candidate_pool_blocked", "selection_pending"}:
2926
+ selection_failure_type = "viable_donor_found_but_acceptance_weak"
2927
+ else:
2928
+ selection_failure_type = "no_viable_donor_found"
2690
2929
  critical_capability_parity, critical_parity_mismatches = _critical_capability_parity(coverage_gap)
2691
2930
  best_available_donor_class = next(
2692
2931
  (
@@ -2696,11 +2935,39 @@ def _scenario_acceptance_summary(
2696
2935
  ),
2697
2936
  None,
2698
2937
  )
2938
+ if best_available_donor_class is None:
2939
+ family_donor_class_map = {
2940
+ "campus": "campus/core",
2941
+ "service_heavy": "service-heavy",
2942
+ "home_iot": "IoT/home gateway",
2943
+ "wan_security_edge": "WAN/security edge",
2944
+ "ipv6_routing": "IPv6/routing",
2945
+ "l2_security_monitoring": "L2 security/monitoring",
2946
+ "wireless_advanced": "advanced wireless",
2947
+ "automation_controller": "automation/controller",
2948
+ "voice_collaboration": "voice/collaboration",
2949
+ "industrial_iot": "industrial IoT",
2950
+ "physical_media_device": "physical/media device",
2951
+ }
2952
+ best_available_donor_class = family_donor_class_map.get(str(readiness.get("family") or "").strip())
2953
+ if selected_donor_summary:
2954
+ best_rejected_donor_class = None
2955
+ primary_rejection_code = None
2956
+ primary_rejection_layer = None
2957
+ elif best_rejected_donor_class is None:
2958
+ best_rejected_donor_class = best_available_donor_class
2699
2959
  remediation_steps = [
2700
2960
  str(item)
2701
2961
  for item in list(coverage_gap.get("recommended_next_actions", []))
2702
2962
  if str(item).strip()
2703
2963
  ][:3]
2964
+ best_rejected_donor_summary = _best_rejected_donor_summary(
2965
+ best_rejected_donor_class,
2966
+ primary_rejection_code,
2967
+ top_rejection_reasons,
2968
+ )
2969
+ if best_rejected_donor_summary:
2970
+ remediation_steps = [best_rejected_donor_summary, *remediation_steps][:3]
2704
2971
  missing_donor_classes = [best_available_donor_class] if selection_failure_type == "viable_donor_found_but_archetype_misaligned" and best_available_donor_class else []
2705
2972
  critical_runtime_requirements: list[str] = []
2706
2973
  if runtime_blocked:
@@ -2725,10 +2992,14 @@ def _scenario_acceptance_summary(
2725
2992
  "next_best_action": next_best_action,
2726
2993
  "remediation_steps": remediation_steps,
2727
2994
  "best_available_donor_class": best_available_donor_class,
2995
+ "best_rejected_donor_class": best_rejected_donor_class,
2996
+ "best_rejected_donor_summary": best_rejected_donor_summary,
2728
2997
  "missing_donor_classes": missing_donor_classes,
2729
2998
  "critical_runtime_requirements": critical_runtime_requirements,
2730
2999
  "decision_confidence": decision.get("decision_confidence"),
2731
3000
  "blocking_layer": decision.get("blocking_layer"),
3001
+ "primary_rejection_layer": primary_rejection_layer,
3002
+ "primary_rejection_code": primary_rejection_code,
2732
3003
  }
2733
3004
 
2734
3005
 
@@ -2808,6 +3079,7 @@ def _scenario_matrix_row(
2808
3079
  "decision_confidence": summary.get("decision_confidence"),
2809
3080
  "blocking_layer": summary.get("blocking_layer"),
2810
3081
  "best_available_donor_class": summary.get("best_available_donor_class"),
3082
+ "primary_rejection_code": summary.get("primary_rejection_code"),
2811
3083
  "remediation_hint": next((str(item) for item in list(summary.get("remediation_steps", []) or []) if str(item).strip()), None),
2812
3084
  }
2813
3085
 
@@ -2984,7 +3256,7 @@ def generate_from_prompt(
2984
3256
  )
2985
3257
  coverage_gap = _augment_coverage_gap_actions(
2986
3258
  coverage_gap,
2987
- donor_blocking_reason=inspect_packet_tracer_compatibility_donor().blocking_reason,
3259
+ donor_blocking_reason=_inspect_packet_tracer_compatibility_donor_cached().blocking_reason,
2988
3260
  )
2989
3261
  scenario_generate_decision = _scenario_generate_decision(coverage_gap)
2990
3262
  prepared_plan.remote_search_results = remote_results
@@ -3084,7 +3356,7 @@ def _explain_plan_payload(
3084
3356
  )
3085
3357
  plan = _apply_prompt_compatibility_requirements(raw_plan, resolved_donor_roots)
3086
3358
  plan.remote_search_results = remote_results
3087
- donor_details = inspect_packet_tracer_compatibility_donor()
3359
+ donor_details = _inspect_packet_tracer_compatibility_donor_cached()
3088
3360
  compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
3089
3361
  topology_tags = _topology_tags_for_plan(plan, _choose_topology_archetype(plan))
3090
3362
  result: dict[str, object] = {
@@ -3213,8 +3485,22 @@ def _explain_plan_payload(
3213
3485
  try:
3214
3486
  if evaluation is None:
3215
3487
  raise PlanningError("Prompt plan is incomplete; generation was skipped.", prepared)
3216
- adapted_plan, donor_archetype, donor_root, _ = evaluation
3488
+ adapted_plan, donor_archetype, donor_root, selected_candidate = evaluation
3217
3489
  safe_plan, profiled_plan = _apply_safe_open_profile(donor_root, adapted_plan)
3490
+ matrix_hits, coverage_gap, blueprint_plan = _build_support_reports(
3491
+ profiled_plan,
3492
+ blueprint=blueprint,
3493
+ cisco_ranked=cisco_ranked,
3494
+ curated_ranked=curated_ranked,
3495
+ reference_catalog=reference_catalog,
3496
+ selected_donor=selected_candidate.sample.relative_path,
3497
+ )
3498
+ coverage_gap = _augment_coverage_gap_actions(
3499
+ coverage_gap,
3500
+ donor_diagnostics=diagnostics,
3501
+ donor_selection_summary=result["donor_selection_summary"],
3502
+ donor_blocking_reason=donor_details.blocking_reason,
3503
+ )
3218
3504
  profiled_plan.remote_search_results = remote_results
3219
3505
  profiled_plan.capability_matrix_hits = matrix_hits
3220
3506
  profiled_plan.coverage_gap_report = coverage_gap
@@ -3269,6 +3555,11 @@ def _explain_plan_payload(
3269
3555
  result["scenario_acceptance_summary"],
3270
3556
  selected_donor_summary=result["selected_donor_summary"],
3271
3557
  )
3558
+ result["capability_matrix_hits"] = matrix_hits
3559
+ result["unsupported_capabilities"] = coverage_gap.get("unsupported_capabilities", [])
3560
+ result["coverage_gaps"] = coverage_gap
3561
+ result["capability_parity"] = list(coverage_gap.get("capability_parity", []))
3562
+ result["blueprint_plan"] = blueprint_plan
3272
3563
  except PlanningError as exc:
3273
3564
  if result["donor_rejection_reasons"]:
3274
3565
  for item in result["donor_rejection_reasons"]:
@@ -3377,7 +3668,7 @@ def inventory_pkt(
3377
3668
  root = ET.fromstring(decode_pkt_modern(pkt_path.read_bytes()))
3378
3669
  payload = inventory_root(root)
3379
3670
  workspace = inspect_workspace_integrity(root)
3380
- donor_details = inspect_packet_tracer_compatibility_donor()
3671
+ donor_details = _inspect_packet_tracer_compatibility_donor_cached()
3381
3672
  compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
3382
3673
  payload["workspace_validation"] = {
3383
3674
  "workspace_mode": workspace.workspace_mode,
@@ -3635,6 +3926,10 @@ def compare_scenarios(
3635
3926
  print(rendered)
3636
3927
 
3637
3928
 
3929
+ def feature_gap_report() -> None:
3930
+ print(json.dumps(build_feature_gap_report(), indent=2, ensure_ascii=False))
3931
+
3932
+
3638
3933
  def main() -> None:
3639
3934
  parser = argparse.ArgumentParser(description="Generate or inspect Cisco Packet Tracer 9.0 .pkt files")
3640
3935
  parser.add_argument("--blueprint", help="Path to the topology blueprint JSON")
@@ -3658,6 +3953,7 @@ def main() -> None:
3658
3953
  parser.add_argument("--max-remote-results", type=int, default=10, help="Maximum number of remote search results to fetch/import")
3659
3954
  parser.add_argument("--blueprint-out", help="Optional JSON output path for the generated blueprint plan or refusal blueprint")
3660
3955
  parser.add_argument("--coverage-report", action="store_true", help="Print the aggregated capability coverage matrix")
3956
+ parser.add_argument("--feature-gap-report", action="store_true", help="Print the Packet Tracer feature gap atlas report")
3661
3957
  parser.add_argument("--inventory-capabilities", action="store_true", help="Include inferred capability inventory when using --inventory")
3662
3958
  parser.add_argument("--inventory-out", help="Optional JSON output path when using --inventory")
3663
3959
  parser.add_argument("--matrix-out", help="Optional JSON output path when using --compare-scenarios")
@@ -3725,6 +4021,9 @@ def main() -> None:
3725
4021
  if args.coverage_report:
3726
4022
  coverage_report(reference_roots, donor_roots, device_family=args.device_family)
3727
4023
  return
4024
+ if args.feature_gap_report:
4025
+ feature_gap_report()
4026
+ return
3728
4027
  if args.decode:
3729
4028
  if not args.xml_out:
3730
4029
  parser.error("--decode requires --xml-out")