packet-tracer-skill 0.2.1 → 0.2.3

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 (43) hide show
  1. package/CHANGELOG.md +53 -2
  2. package/README.md +443 -124
  3. package/docs/automation-controller-proof.md +35 -0
  4. package/docs/campus-donor-proof.md +103 -0
  5. package/docs/curated-donor-registry.md +37 -0
  6. package/docs/generate-ready-pilot-design.md +30 -0
  7. package/docs/github-launch-ops-0.2.1.md +45 -0
  8. package/docs/github-launch-ops-0.2.2.md +42 -0
  9. package/docs/github-metadata.md +23 -6
  10. package/docs/home-iot-donor-proof.md +64 -0
  11. package/docs/industrial-programming-proof.md +48 -0
  12. package/docs/ipv4-routing-management-proof.md +37 -0
  13. package/docs/l2-resiliency-bgp-proof.md +60 -0
  14. package/docs/l2-security-qos-proof.md +59 -0
  15. package/docs/launch-announcement-0.2.2.md +15 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +244 -0
  17. package/docs/post-launch-follow-up.md +39 -0
  18. package/docs/publish-preview-roadmap.md +20 -17
  19. package/docs/release-checklist.md +32 -17
  20. package/docs/release-notes-0.2.1.md +7 -6
  21. package/docs/release-notes-0.2.2.md +26 -0
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/runtime-truth.md +31 -0
  24. package/docs/security-edge-deepening-proof.md +65 -0
  25. package/docs/voice-collaboration-proof.md +38 -0
  26. package/docs/wan-security-donor-proof.md +58 -0
  27. package/docs/wireless-advanced-proof.md +60 -0
  28. package/examples/README.md +11 -8
  29. package/examples/gallery.md +9 -3
  30. package/examples/index.json +3 -3
  31. package/package.json +20 -1
  32. package/references/curated-donor-registry.json +5 -2
  33. package/references/packettracer-feature-atlas.json +183 -0
  34. package/references/scenario-fixture-corpus.json +1 -0
  35. package/scripts/build_examples_index.py +26 -15
  36. package/scripts/coverage_matrix.py +1054 -25
  37. package/scripts/feature_atlas.py +293 -0
  38. package/scripts/generate_pkt.py +491 -34
  39. package/scripts/intent_parser.py +798 -8
  40. package/scripts/pkt_editor.py +651 -0
  41. package/scripts/remote_search.py +197 -21
  42. package/scripts/runtime_doctor.py +83 -10
  43. package/scripts/sample_catalog.py +266 -6
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import copy
7
+ from collections import Counter, defaultdict
7
8
  from dataclasses import asdict, dataclass, field
9
+ from functools import lru_cache
8
10
  import json
9
11
  import os
10
12
  from pathlib import Path
@@ -25,6 +27,7 @@ from coverage_matrix import (
25
27
  build_inventory_capability_report,
26
28
  select_capability_matrix_hits,
27
29
  )
30
+ from feature_atlas import build_feature_gap_report
28
31
  from intent_parser import IntentPlan, parse_intent
29
32
  from packet_tracer_env import (
30
33
  get_packet_tracer_compatibility_donor,
@@ -35,7 +38,13 @@ from pkt_builder import build_packet_tracer_xml
35
38
  from pkt_codec import decode_pkt_file, decode_pkt_modern, encode_pkt_modern
36
39
  from pkt_editor import apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
37
40
  from pkt_transformer import transform_from_blueprint
38
- from remote_search import asdict_list as remote_asdict_list, auto_import_remote_candidates, search_remote_candidates
41
+ from remote_search import (
42
+ asdict_list as remote_asdict_list,
43
+ auto_import_remote_candidates,
44
+ candidate_is_curated_eligible,
45
+ search_remote_candidates,
46
+ write_remote_sample_audit,
47
+ )
39
48
  from sample_catalog import ReferencePattern, SampleCandidate, SampleDescriptor, load_catalog, load_curated_donor_catalog, load_reference_catalog, summarize_pkt_descriptor
40
49
  from sample_selector import rank_curated_donor_samples, rank_reference_samples, rank_samples, select_best_sample
41
50
  from workspace_repair import inspect_donor_coherence, inspect_workspace_integrity, validate_donor_coherence, validate_workspace_integrity
@@ -81,6 +90,9 @@ DEVICE_FAMILY_MAP = {
81
90
  "Tablet": "end devices",
82
91
  "Smartphone": "end devices",
83
92
  "Printer": "end devices",
93
+ "IpPhone": "end devices",
94
+ "HomeVoip": "end devices",
95
+ "AnalogPhone": "end devices",
84
96
  "LightWeightAccessPoint": "access points",
85
97
  "WirelessRouter": "home/wireless routers",
86
98
  "WirelessRouterNewGeneration": "home/wireless routers",
@@ -107,6 +119,11 @@ if hasattr(sys.stdout, "reconfigure"):
107
119
  pass
108
120
 
109
121
 
122
+ @lru_cache(maxsize=1)
123
+ def _inspect_packet_tracer_compatibility_donor_cached():
124
+ return inspect_packet_tracer_compatibility_donor()
125
+
126
+
110
127
  class PlanningError(RuntimeError):
111
128
  def __init__(self, message: str, plan: IntentPlan) -> None:
112
129
  super().__init__(message)
@@ -139,7 +156,7 @@ STRICT_COMPATIBILITY_GAP = (
139
156
 
140
157
 
141
158
  def _compat_donor_details() -> tuple[Path | None, str | None]:
142
- details = inspect_packet_tracer_compatibility_donor()
159
+ details = _inspect_packet_tracer_compatibility_donor_cached()
143
160
  return details.resolved_path, details.donor_version
144
161
 
145
162
 
@@ -244,6 +261,8 @@ def _resolve_remote_sources(
244
261
  remote_provider: str = "github",
245
262
  import_cache_root: Path | None = None,
246
263
  max_remote_results: int = 10,
264
+ remote_dry_run: bool = False,
265
+ remote_audit_out: Path | None = None,
247
266
  ) -> tuple[list[Path], list[Path], list[dict[str, object]]]:
248
267
  resolved_reference_roots = list(reference_roots or [])
249
268
  resolved_donor_roots = list(donor_roots or [])
@@ -251,13 +270,19 @@ def _resolve_remote_sources(
251
270
  return resolved_reference_roots, resolved_donor_roots, []
252
271
  remote_candidates = search_remote_candidates(plan, provider=remote_provider, max_results=max_remote_results)
253
272
  cache_root = import_cache_root or _default_import_cache_root()
254
- imported_candidates = auto_import_remote_candidates(remote_candidates, cache_root, max_results=max_remote_results)
273
+ imported_candidates = auto_import_remote_candidates(
274
+ remote_candidates,
275
+ cache_root,
276
+ max_results=max_remote_results,
277
+ dry_run=remote_dry_run,
278
+ )
279
+ write_remote_sample_audit(imported_candidates, cache_root, remote_audit_out)
255
280
  for candidate in imported_candidates:
256
281
  if candidate.path:
257
282
  imported_root = Path(candidate.path)
258
283
  if imported_root not in resolved_reference_roots:
259
284
  resolved_reference_roots.append(imported_root)
260
- if imported_root not in resolved_donor_roots:
285
+ if candidate_is_curated_eligible(candidate) and imported_root not in resolved_donor_roots:
261
286
  resolved_donor_roots.append(imported_root)
262
287
  return resolved_reference_roots, resolved_donor_roots, remote_asdict_list(imported_candidates)
263
288
 
@@ -327,28 +352,100 @@ def _preferred_donor_archetypes_for_plan(plan: IntentPlan, topology_tags: list[s
327
352
  }
328
353
  capabilities = set(plan.capabilities)
329
354
  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 & {
355
+ prompt_lower = str(plan.prompt or "").lower()
356
+ explicit_home_gateway_prompt = any(
357
+ token in prompt_lower
358
+ for token in ["home gateway", "homegateway", "smart home", "iot "]
359
+ )
360
+ family_hints: list[str] = []
361
+ campus_signal = (
362
+ plan.network_style == "campus"
363
+ or "campus" in prompt_lower
364
+ or "kampus" in prompt_lower
365
+ or bool(plan.department_groups)
366
+ or bool(tags & {"chain", "core_access", "router_on_a_stick", "acl_policy"})
367
+ or bool(
368
+ device_families & {"routers", "switches", "multilayer switches"}
369
+ and capabilities & {"vlan", "router_on_a_stick", "management_vlan", "telnet", "acl", "trunk", "access_port"}
370
+ )
371
+ )
372
+ home_iot_signal = (
373
+ plan.network_style in {"home_iot", "smart_home"}
374
+ or explicit_home_gateway_prompt
375
+ or plan.wireless_mode == "home_router_edge"
376
+ or bool(capabilities & {"iot", "iot_registration", "iot_control"})
377
+ or bool("iot devices" in device_families)
378
+ )
379
+ wan_signal = (
380
+ plan.network_style == "wan_security"
381
+ or bool(device_families & {"wan/cloud/dsl/cable devices", "security devices"})
382
+ or bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "multilayer_switching", "security_edge"})
383
+ )
384
+ 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"})
385
+ ipv4_routing_management_signal = plan.network_style == "ipv4_routing_management" or bool(capabilities & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"})
386
+ 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"})
387
+ wireless_advanced_signal = plan.network_style == "wireless_advanced" or bool(capabilities & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"})
388
+ automation_controller_signal = plan.network_style == "automation_controller" or bool(capabilities & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"})
389
+ voice_collaboration_signal = plan.network_style == "voice_collaboration" or bool(capabilities & {"voip", "ip_phone", "call_manager", "linksys_voice"})
390
+ industrial_iot_signal = plan.network_style == "industrial_iot" or bool(capabilities & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"})
391
+ wireless_signal = plan.wireless_mode or capabilities & {
342
392
  "wireless_ap",
343
393
  "wireless_client",
344
394
  "wireless_mutation",
345
395
  "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")
396
+ }
397
+ service_signal = (
398
+ requested_services & {"dns", "dhcp", "http", "https", "ftp", "tftp", "email", "syslog", "aaa", "ntp"}
399
+ or capabilities & {"server_dns", "server_dhcp", "server_http", "server_https", "server_ftp", "server_tftp", "server_email", "server_syslog", "server_aaa"}
400
+ or "servers" in device_families
401
+ )
402
+
403
+ if industrial_iot_signal:
404
+ family_hints.append("industrial IoT")
405
+ elif automation_controller_signal:
406
+ family_hints.append("automation/controller")
407
+ elif voice_collaboration_signal:
408
+ family_hints.append("voice/collaboration")
409
+ elif wireless_advanced_signal:
410
+ family_hints.append("advanced wireless")
411
+ elif l2_security_monitoring_signal:
412
+ family_hints.append("L2 security/monitoring")
413
+ elif ipv6_routing_signal:
414
+ family_hints.append("IPv6/routing")
415
+ elif ipv4_routing_management_signal:
416
+ family_hints.append("IPv4 routing/management")
417
+ elif wan_signal:
418
+ family_hints.append("WAN/security edge")
419
+ if campus_signal and not bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "security_edge"}):
420
+ family_hints.append("campus/core")
421
+ elif campus_signal:
422
+ family_hints.append("campus/core")
423
+ if wireless_signal:
424
+ family_hints.append("wireless-heavy")
425
+ if service_signal and requested_services & {"email", "syslog", "aaa", "http", "https", "ftp", "tftp"}:
426
+ family_hints.append("service-heavy")
427
+ elif home_iot_signal:
428
+ family_hints.append("IoT/home gateway")
429
+ if wireless_signal:
430
+ family_hints.append("wireless-heavy")
431
+ elif service_signal:
432
+ family_hints.append("service-heavy")
433
+ elif wireless_signal:
434
+ family_hints.append("wireless-heavy")
435
+
436
+ if not family_hints:
437
+ if service_signal:
438
+ family_hints.append("service-heavy")
439
+ if wireless_signal:
440
+ family_hints.append("wireless-heavy")
441
+ if home_iot_signal:
442
+ family_hints.append("IoT/home gateway")
443
+ if wan_signal:
444
+ family_hints.append("WAN/security edge")
445
+
446
+ for item in family_hints:
447
+ if item not in preferred:
448
+ preferred.append(item)
352
449
  return preferred
353
450
 
354
451
 
@@ -361,6 +458,18 @@ def _required_runtime_features_for_plan(plan: IntentPlan) -> list[str]:
361
458
  features.add("wireless_runtime")
362
459
  if capabilities & {"iot", "iot_registration", "iot_control"}:
363
460
  features.add("iot_runtime")
461
+ if capabilities & {"vpn", "ipsec", "gre"}:
462
+ features.add("tunnel_runtime")
463
+ if capabilities & {"ppp"}:
464
+ features.add("wan_runtime")
465
+ if capabilities & {"security_edge", "acl", "nat"}:
466
+ features.add("security_runtime")
467
+ if capabilities & {"multilayer_switching"}:
468
+ features.add("multilayer_runtime")
469
+ if capabilities & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
470
+ features.add("ipv6_runtime")
471
+ if capabilities & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
472
+ features.add("ipv4_routing_management_runtime")
364
473
  return sorted(features)
365
474
 
366
475
 
@@ -437,6 +546,20 @@ def _candidate_acceptance_penalty(candidate: SampleCandidate, blueprint: dict[st
437
546
  "wireless_mutation": 18,
438
547
  "wireless_client_association": 22,
439
548
  "end_device_mutation": 12,
549
+ "iot_registration": 26,
550
+ "iot_control": 24,
551
+ "vpn": 22,
552
+ "ipsec": 22,
553
+ "gre": 20,
554
+ "ppp": 18,
555
+ "security_edge": 20,
556
+ "multilayer_switching": 16,
557
+ "ipv6_slaac": 18,
558
+ "dhcpv6_stateful": 18,
559
+ "ospfv3": 18,
560
+ "eigrp_ipv6": 18,
561
+ "ripng": 16,
562
+ "hsrp": 16,
440
563
  }.items():
441
564
  if capability in requested_capabilities and capability not in supported_capabilities:
442
565
  penalty += penalty_value
@@ -507,15 +630,110 @@ def _summarize_candidate_pool(
507
630
  break
508
631
  if len(top_rejection_reasons) >= 5:
509
632
  continue
633
+ primary_rejection_code = _primary_rejection_code(top_rejection_reasons)
634
+ primary_rejection_layer = _primary_rejection_layer(primary_rejection_code)
635
+ best_rejected_donor_class = (
636
+ next((str(item) for item in list(preferred_archetypes or []) if str(item).strip()), None)
637
+ if any(counts[key] > 0 for key in ("rejected", "filtered"))
638
+ else None
639
+ )
510
640
  return {
511
641
  "preferred_donor_archetypes": list(preferred_archetypes or []),
512
642
  "candidate_counts": counts,
513
643
  "best_adjusted_total_score": best_adjusted_score,
514
644
  "best_layout_reuse_score": best_layout_reuse_score,
515
645
  "top_rejection_reasons": top_rejection_reasons,
646
+ "best_rejected_donor_class": best_rejected_donor_class,
647
+ "primary_rejection_code": primary_rejection_code,
648
+ "primary_rejection_layer": primary_rejection_layer,
516
649
  }
517
650
 
518
651
 
652
+ def _primary_rejection_code(rejection_reasons: list[str]) -> str | None:
653
+ normalized = [str(item).strip().lower() for item in rejection_reasons if str(item).strip()]
654
+ if any("runtime subtree" in reason for reason in normalized):
655
+ return "runtime_subtree_missing"
656
+ if any("archetype_gap:" in reason or "archetype does not align" in reason for reason in normalized):
657
+ return "archetype_misaligned"
658
+ if any(
659
+ marker in reason
660
+ for reason in normalized
661
+ for marker in (
662
+ "missing_link_pairs:",
663
+ "no reusable link pairs",
664
+ "reuses too little of the requested link skeleton",
665
+ "cannot create new donor link pair",
666
+ "requires donor link reuse",
667
+ "port mismatch",
668
+ "media mismatch",
669
+ "ports/media",
670
+ )
671
+ ):
672
+ return "layout_reuse_too_weak"
673
+ if any(
674
+ marker in reason
675
+ for reason in normalized
676
+ for marker in (
677
+ "acceptance penalty",
678
+ "acceptance_gated",
679
+ "acceptance-gated",
680
+ "acceptance fixture",
681
+ "acceptance evidence",
682
+ "acceptance risk",
683
+ )
684
+ ):
685
+ return "acceptance_evidence_too_weak"
686
+ return None
687
+
688
+
689
+ def _primary_rejection_layer(primary_rejection_code: str | None) -> str | None:
690
+ if primary_rejection_code == "acceptance_evidence_too_weak":
691
+ return "acceptance"
692
+ if primary_rejection_code in {
693
+ "layout_reuse_too_weak",
694
+ "archetype_misaligned",
695
+ "runtime_subtree_missing",
696
+ }:
697
+ return "donor"
698
+ return None
699
+
700
+
701
+ def _best_rejected_donor_summary(
702
+ best_rejected_donor_class: str | None,
703
+ primary_rejection_code: str | None,
704
+ top_rejection_reasons: list[str],
705
+ ) -> str | None:
706
+ if not best_rejected_donor_class and not primary_rejection_code:
707
+ return None
708
+ reason_sentence_map = {
709
+ "layout_reuse_too_weak": "its reusable link skeleton is still too weak for the requested topology",
710
+ "acceptance_evidence_too_weak": "its acceptance evidence is still too weak for prompt-level generate",
711
+ "archetype_misaligned": "its donor shape does not align with the requested scenario archetype",
712
+ "runtime_subtree_missing": "the required runtime subtree is missing from the donor path",
713
+ }
714
+ next_shape_map = {
715
+ "layout_reuse_too_weak": "choose a donor with the same family but a stronger reusable router-switch-service skeleton",
716
+ "acceptance_evidence_too_weak": "choose a donor with explicit acceptance-backed coverage for the same scenario family",
717
+ "archetype_misaligned": "choose a donor whose archetype already matches the requested family",
718
+ "runtime_subtree_missing": "choose a donor that already contains the required runtime subtree and open-state structure",
719
+ }
720
+ donor_class = best_rejected_donor_class or "the closest donor class"
721
+ reason = reason_sentence_map.get(primary_rejection_code or "", "it still did not satisfy strict donor selection")
722
+ next_shape = next_shape_map.get(primary_rejection_code or "", "choose a closer donor before prompt generate")
723
+ if donor_class == "WAN/security edge":
724
+ wan_next_shape_map = {
725
+ "layout_reuse_too_weak": "choose a WAN/security donor with reusable ASA/cloud/serial or tunnel skeleton",
726
+ "acceptance_evidence_too_weak": "choose a WAN/security donor with explicit VPN/IPSec/GRE/PPP acceptance evidence",
727
+ "archetype_misaligned": "choose a donor already tagged as WAN/security edge",
728
+ "runtime_subtree_missing": "choose a donor that already contains the WAN/security runtime subtree",
729
+ }
730
+ next_shape = wan_next_shape_map.get(primary_rejection_code or "", "choose a closer WAN/security donor before prompt generate")
731
+ summary = f"Best rejected donor class {donor_class} was closest, but {reason}; {next_shape}."
732
+ if top_rejection_reasons:
733
+ summary = f"{summary} Top rejection signal: {top_rejection_reasons[0]}."
734
+ return summary
735
+
736
+
519
737
  def _load_fixture_corpus() -> dict[str, object]:
520
738
  if not FIXTURE_CORPUS_PATH.exists():
521
739
  return {"fixture_registry_version": "missing", "fixtures": []}
@@ -594,12 +812,23 @@ def _critical_capability_parity(coverage_gap: dict[str, object]) -> tuple[list[d
594
812
  def _parity_counts(parity_entries: list[dict[str, object]]) -> dict[str, int]:
595
813
  return {
596
814
  "parity_supported_count": sum(1 for entry in parity_entries if bool(entry.get("inventory_supported"))),
815
+ "parity_donor_backed_ready_count": sum(1 for entry in parity_entries if bool(entry.get("donor_backed_ready"))),
597
816
  "parity_generate_ready_count": sum(1 for entry in parity_entries if bool(entry.get("generate_supported"))),
598
817
  "parity_acceptance_verified_count": sum(1 for entry in parity_entries if bool(entry.get("acceptance_verified"))),
599
818
  "parity_mismatch_count": sum(1 for entry in parity_entries if entry.get("generate_mismatch_reason")),
600
819
  }
601
820
 
602
821
 
822
+ def _critical_parity_counts(critical_entries: list[dict[str, object]]) -> dict[str, int]:
823
+ return {
824
+ "critical_parity_supported_count": sum(1 for entry in critical_entries if bool(entry.get("inventory_supported"))),
825
+ "critical_parity_donor_backed_ready_count": sum(1 for entry in critical_entries if bool(entry.get("donor_backed_ready"))),
826
+ "critical_parity_generate_ready_count": sum(1 for entry in critical_entries if bool(entry.get("generate_supported"))),
827
+ "critical_parity_acceptance_verified_count": sum(1 for entry in critical_entries if bool(entry.get("acceptance_verified"))),
828
+ "critical_parity_mismatch_count": sum(1 for entry in critical_entries if entry.get("generate_mismatch_reason")),
829
+ }
830
+
831
+
603
832
  def _selected_donor_summary(
604
833
  diagnostics: list[dict[str, object]],
605
834
  donor_archetype: DonorArchetypePlan | None = None,
@@ -699,6 +928,10 @@ def _filter_candidates_for_blueprint(
699
928
  filter_reasons.append("sample lacks donor-backed support for requested wireless mutation")
700
929
  if "end_device_mutation" in required_capabilities and "end_device_mutation" not in supported_capabilities:
701
930
  filter_reasons.append("sample lacks donor-backed support for requested end-device mutation")
931
+ if "iot_registration" in required_capabilities and "iot_registration" not in supported_capabilities:
932
+ filter_reasons.append("sample lacks donor-backed support for requested IoT registration")
933
+ if "iot_control" in required_capabilities and "iot_control" not in supported_capabilities:
934
+ filter_reasons.append("sample lacks donor-backed support for requested IoT control")
702
935
  if adjusted_total_score <= 0:
703
936
  filter_reasons.append("acceptance penalty reduced the donor score below zero")
704
937
  if filter_reasons:
@@ -2472,8 +2705,11 @@ def _augment_coverage_gap_actions(
2472
2705
  actions.append("For service-heavy prompts, prefer a donor that already contains the required server service family and core server layout.")
2473
2706
  if scenario_family == "home_iot" and scenario_status in {"donor_limited", "acceptance_gated", "unsupported"}:
2474
2707
  actions.append("For home IoT prompts, prefer a donor with Home Gateway plus existing IoT registration/control structure.")
2708
+ actions.append("For donor-backed Home IoT generate, explicitly name the thing, gateway/server target, and wireless client or SSID targets.")
2475
2709
  if scenario_family == "wan_security_edge" and scenario_status in {"donor_limited", "acceptance_gated", "unsupported"}:
2476
2710
  actions.append("For WAN/security prompts, prefer a donor with reusable serial/WAN or security-edge skeleton before generating.")
2711
+ 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"}:
2712
+ actions.append("This feature family is atlas/report-first; keep it out of strict generate until donor-backed proof and acceptance fixtures exist.")
2477
2713
  updated["recommended_next_actions"] = list(dict.fromkeys(actions))
2478
2714
  return updated
2479
2715
 
@@ -2524,12 +2760,26 @@ def _scenario_generate_decision(
2524
2760
  "service_heavy": "service-heavy",
2525
2761
  "home_iot": "home IoT",
2526
2762
  "wan_security_edge": "WAN/security edge",
2763
+ "ipv6_routing": "IPv6/routing",
2764
+ "l2_security_monitoring": "L2 security/monitoring",
2765
+ "wireless_advanced": "advanced wireless",
2766
+ "automation_controller": "automation/controller",
2767
+ "voice_collaboration": "voice/collaboration",
2768
+ "industrial_iot": "industrial IoT",
2769
+ "physical_media_device": "physical/media device",
2527
2770
  }
2528
2771
  expected_archetype_map = {
2529
2772
  "campus": "campus/core",
2530
2773
  "service_heavy": "service-heavy",
2531
2774
  "home_iot": "IoT/home gateway",
2532
2775
  "wan_security_edge": "WAN/security edge",
2776
+ "ipv6_routing": "IPv6/routing",
2777
+ "l2_security_monitoring": "L2 security/monitoring",
2778
+ "wireless_advanced": "advanced wireless",
2779
+ "automation_controller": "automation/controller",
2780
+ "voice_collaboration": "voice/collaboration",
2781
+ "industrial_iot": "industrial IoT",
2782
+ "physical_media_device": "physical/media device",
2533
2783
  }
2534
2784
  family_label = family_label_map.get(family, family)
2535
2785
  blocking_reasons: list[str] = []
@@ -2543,6 +2793,13 @@ def _scenario_generate_decision(
2543
2793
  expected_archetype = expected_archetype_map.get(family)
2544
2794
  sample_archetypes = [str(item) for item in list((selected_donor_summary or {}).get("sample_archetypes", [])) if str(item).strip()]
2545
2795
  donor_graph_summary = dict((selected_donor_summary or {}).get("donor_graph_summary") or {})
2796
+ best_rejected_donor_class = str((donor_selection_summary or {}).get("best_rejected_donor_class") or "").strip() or None
2797
+ primary_rejection_code = str((donor_selection_summary or {}).get("primary_rejection_code") or "").strip() or None
2798
+ best_rejected_donor_summary = _best_rejected_donor_summary(
2799
+ best_rejected_donor_class,
2800
+ primary_rejection_code,
2801
+ [str(item) for item in list((donor_selection_summary or {}).get("top_rejection_reasons", [])) if str(item).strip()],
2802
+ )
2546
2803
  if expected_archetype and sample_archetypes:
2547
2804
  aligned = expected_archetype in sample_archetypes
2548
2805
  decision["selected_donor_aligned"] = aligned
@@ -2590,6 +2847,8 @@ def _scenario_generate_decision(
2590
2847
  blocking_reasons.append(
2591
2848
  f"Scenario '{family_label}' depends on donor-limited safe-open coverage, but no compatible donor was selected."
2592
2849
  )
2850
+ if best_rejected_donor_summary:
2851
+ notes.append(best_rejected_donor_summary)
2593
2852
  layout_status = str(donor_graph_summary.get("layout_reuse_status") or "").strip()
2594
2853
  pair_coverage = donor_graph_summary.get("reusable_pair_coverage")
2595
2854
  if layout_status == "weak":
@@ -2600,6 +2859,18 @@ def _scenario_generate_decision(
2600
2859
  blocking_reasons.append(
2601
2860
  f"Scenario '{family_label}' donor selection has no reusable link-pair coverage for prompt generate."
2602
2861
  )
2862
+ if primary_rejection_code == "archetype_misaligned":
2863
+ blocking_reasons.append(
2864
+ f"Scenario '{family_label}' best donor class is still archetype-misaligned with the requested prompt shape."
2865
+ )
2866
+ elif primary_rejection_code == "runtime_subtree_missing":
2867
+ blocking_reasons.append(
2868
+ f"Scenario '{family_label}' donor candidates are missing required runtime subtree coverage for strict reuse."
2869
+ )
2870
+ elif primary_rejection_code == "acceptance_evidence_too_weak":
2871
+ blocking_reasons.append(
2872
+ f"Scenario '{family_label}' donor candidates still lack strong enough acceptance evidence for strict prompt generate."
2873
+ )
2603
2874
  if layout_status in {"strong", "partial"} and isinstance(pair_coverage, int) and pair_coverage > 0:
2604
2875
  notes.append(f"selected donor provides {pair_coverage}% reusable link-pair coverage ({layout_status})")
2605
2876
  if selected_donor_summary and not blocking_reasons:
@@ -2668,12 +2939,6 @@ def _scenario_acceptance_summary(
2668
2939
  donor_state = "candidate_pool_blocked"
2669
2940
  if donor_state == "not_selected" and int(candidate_counts.get("selected", 0) or 0) > 0:
2670
2941
  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
2942
  key_reasons = [str(item) for item in decision.get("blocking_reasons", []) if str(item).strip()]
2678
2943
  if not key_reasons:
2679
2944
  key_reasons = [str(item) for item in decision.get("notes", []) if str(item).strip()]
@@ -2682,11 +2947,19 @@ def _scenario_acceptance_summary(
2682
2947
  None,
2683
2948
  )
2684
2949
  top_rejection_reasons = [str(item) for item in list((donor_selection_summary or {}).get("top_rejection_reasons", [])) if str(item).strip()]
2950
+ primary_rejection_code = str((donor_selection_summary or {}).get("primary_rejection_code") or "").strip() or _primary_rejection_code(top_rejection_reasons)
2951
+ primary_rejection_layer = str((donor_selection_summary or {}).get("primary_rejection_layer") or "").strip() or _primary_rejection_layer(primary_rejection_code)
2952
+ best_rejected_donor_class = str((donor_selection_summary or {}).get("best_rejected_donor_class") or "").strip() or None
2953
+ selection_failure_type = None
2685
2954
  if not selected_donor_summary:
2686
- if any("archetype_gap:" in reason for reason in top_rejection_reasons):
2955
+ if primary_rejection_code == "archetype_misaligned":
2687
2956
  selection_failure_type = "viable_donor_found_but_archetype_misaligned"
2688
- elif any("runtime subtree" in reason.lower() for reason in top_rejection_reasons):
2957
+ elif primary_rejection_code == "runtime_subtree_missing":
2689
2958
  selection_failure_type = "viable_donor_found_but_runtime_subtree_missing"
2959
+ elif donor_state in {"candidate_pool_blocked", "selection_pending"}:
2960
+ selection_failure_type = "viable_donor_found_but_acceptance_weak"
2961
+ else:
2962
+ selection_failure_type = "no_viable_donor_found"
2690
2963
  critical_capability_parity, critical_parity_mismatches = _critical_capability_parity(coverage_gap)
2691
2964
  best_available_donor_class = next(
2692
2965
  (
@@ -2696,11 +2969,39 @@ def _scenario_acceptance_summary(
2696
2969
  ),
2697
2970
  None,
2698
2971
  )
2972
+ if best_available_donor_class is None:
2973
+ family_donor_class_map = {
2974
+ "campus": "campus/core",
2975
+ "service_heavy": "service-heavy",
2976
+ "home_iot": "IoT/home gateway",
2977
+ "wan_security_edge": "WAN/security edge",
2978
+ "ipv6_routing": "IPv6/routing",
2979
+ "l2_security_monitoring": "L2 security/monitoring",
2980
+ "wireless_advanced": "advanced wireless",
2981
+ "automation_controller": "automation/controller",
2982
+ "voice_collaboration": "voice/collaboration",
2983
+ "industrial_iot": "industrial IoT",
2984
+ "physical_media_device": "physical/media device",
2985
+ }
2986
+ best_available_donor_class = family_donor_class_map.get(str(readiness.get("family") or "").strip())
2987
+ if selected_donor_summary:
2988
+ best_rejected_donor_class = None
2989
+ primary_rejection_code = None
2990
+ primary_rejection_layer = None
2991
+ elif best_rejected_donor_class is None:
2992
+ best_rejected_donor_class = best_available_donor_class
2699
2993
  remediation_steps = [
2700
2994
  str(item)
2701
2995
  for item in list(coverage_gap.get("recommended_next_actions", []))
2702
2996
  if str(item).strip()
2703
2997
  ][:3]
2998
+ best_rejected_donor_summary = _best_rejected_donor_summary(
2999
+ best_rejected_donor_class,
3000
+ primary_rejection_code,
3001
+ top_rejection_reasons,
3002
+ )
3003
+ if best_rejected_donor_summary:
3004
+ remediation_steps = [best_rejected_donor_summary, *remediation_steps][:3]
2704
3005
  missing_donor_classes = [best_available_donor_class] if selection_failure_type == "viable_donor_found_but_archetype_misaligned" and best_available_donor_class else []
2705
3006
  critical_runtime_requirements: list[str] = []
2706
3007
  if runtime_blocked:
@@ -2721,14 +3022,19 @@ def _scenario_acceptance_summary(
2721
3022
  "missing_critical_capabilities": [str(item) for item in list(readiness.get("missing_critical_capabilities", [])) if str(item).strip()],
2722
3023
  "critical_capability_parity": critical_capability_parity,
2723
3024
  "critical_parity_mismatches": critical_parity_mismatches,
3025
+ **_critical_parity_counts(critical_capability_parity),
2724
3026
  "key_reasons": key_reasons[:3],
2725
3027
  "next_best_action": next_best_action,
2726
3028
  "remediation_steps": remediation_steps,
2727
3029
  "best_available_donor_class": best_available_donor_class,
3030
+ "best_rejected_donor_class": best_rejected_donor_class,
3031
+ "best_rejected_donor_summary": best_rejected_donor_summary,
2728
3032
  "missing_donor_classes": missing_donor_classes,
2729
3033
  "critical_runtime_requirements": critical_runtime_requirements,
2730
3034
  "decision_confidence": decision.get("decision_confidence"),
2731
3035
  "blocking_layer": decision.get("blocking_layer"),
3036
+ "primary_rejection_layer": primary_rejection_layer,
3037
+ "primary_rejection_code": primary_rejection_code,
2732
3038
  }
2733
3039
 
2734
3040
 
@@ -2808,6 +3114,7 @@ def _scenario_matrix_row(
2808
3114
  "decision_confidence": summary.get("decision_confidence"),
2809
3115
  "blocking_layer": summary.get("blocking_layer"),
2810
3116
  "best_available_donor_class": summary.get("best_available_donor_class"),
3117
+ "primary_rejection_code": summary.get("primary_rejection_code"),
2811
3118
  "remediation_hint": next((str(item) for item in list(summary.get("remediation_steps", []) or []) if str(item).strip()), None),
2812
3119
  }
2813
3120
 
@@ -2944,6 +3251,8 @@ def generate_from_prompt(
2944
3251
  remote_provider: str = "github",
2945
3252
  import_cache_root: Path | None = None,
2946
3253
  max_remote_results: int = 10,
3254
+ remote_dry_run: bool = False,
3255
+ remote_audit_out: Path | None = None,
2947
3256
  blueprint_out_path: Path | None = None,
2948
3257
  ) -> None:
2949
3258
  raw_plan = parse_intent(prompt)
@@ -2955,6 +3264,8 @@ def generate_from_prompt(
2955
3264
  remote_provider=remote_provider,
2956
3265
  import_cache_root=import_cache_root,
2957
3266
  max_remote_results=max_remote_results,
3267
+ remote_dry_run=remote_dry_run,
3268
+ remote_audit_out=remote_audit_out,
2958
3269
  )
2959
3270
  raw_plan.remote_search_results = remote_results
2960
3271
  if raw_plan.goal == "edit" and raw_plan.pkt_path:
@@ -2984,7 +3295,7 @@ def generate_from_prompt(
2984
3295
  )
2985
3296
  coverage_gap = _augment_coverage_gap_actions(
2986
3297
  coverage_gap,
2987
- donor_blocking_reason=inspect_packet_tracer_compatibility_donor().blocking_reason,
3298
+ donor_blocking_reason=_inspect_packet_tracer_compatibility_donor_cached().blocking_reason,
2988
3299
  )
2989
3300
  scenario_generate_decision = _scenario_generate_decision(coverage_gap)
2990
3301
  prepared_plan.remote_search_results = remote_results
@@ -3071,6 +3382,8 @@ def _explain_plan_payload(
3071
3382
  remote_provider: str = "github",
3072
3383
  import_cache_root: Path | None = None,
3073
3384
  max_remote_results: int = 10,
3385
+ remote_dry_run: bool = False,
3386
+ remote_audit_out: Path | None = None,
3074
3387
  ) -> dict[str, object]:
3075
3388
  raw_plan = parse_intent(prompt)
3076
3389
  resolved_reference_roots, resolved_donor_roots, remote_results = _resolve_remote_sources(
@@ -3081,10 +3394,12 @@ def _explain_plan_payload(
3081
3394
  remote_provider=remote_provider,
3082
3395
  import_cache_root=import_cache_root,
3083
3396
  max_remote_results=max_remote_results,
3397
+ remote_dry_run=remote_dry_run,
3398
+ remote_audit_out=remote_audit_out,
3084
3399
  )
3085
3400
  plan = _apply_prompt_compatibility_requirements(raw_plan, resolved_donor_roots)
3086
3401
  plan.remote_search_results = remote_results
3087
- donor_details = inspect_packet_tracer_compatibility_donor()
3402
+ donor_details = _inspect_packet_tracer_compatibility_donor_cached()
3088
3403
  compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
3089
3404
  topology_tags = _topology_tags_for_plan(plan, _choose_topology_archetype(plan))
3090
3405
  result: dict[str, object] = {
@@ -3213,8 +3528,22 @@ def _explain_plan_payload(
3213
3528
  try:
3214
3529
  if evaluation is None:
3215
3530
  raise PlanningError("Prompt plan is incomplete; generation was skipped.", prepared)
3216
- adapted_plan, donor_archetype, donor_root, _ = evaluation
3531
+ adapted_plan, donor_archetype, donor_root, selected_candidate = evaluation
3217
3532
  safe_plan, profiled_plan = _apply_safe_open_profile(donor_root, adapted_plan)
3533
+ matrix_hits, coverage_gap, blueprint_plan = _build_support_reports(
3534
+ profiled_plan,
3535
+ blueprint=blueprint,
3536
+ cisco_ranked=cisco_ranked,
3537
+ curated_ranked=curated_ranked,
3538
+ reference_catalog=reference_catalog,
3539
+ selected_donor=selected_candidate.sample.relative_path,
3540
+ )
3541
+ coverage_gap = _augment_coverage_gap_actions(
3542
+ coverage_gap,
3543
+ donor_diagnostics=diagnostics,
3544
+ donor_selection_summary=result["donor_selection_summary"],
3545
+ donor_blocking_reason=donor_details.blocking_reason,
3546
+ )
3218
3547
  profiled_plan.remote_search_results = remote_results
3219
3548
  profiled_plan.capability_matrix_hits = matrix_hits
3220
3549
  profiled_plan.coverage_gap_report = coverage_gap
@@ -3269,6 +3598,11 @@ def _explain_plan_payload(
3269
3598
  result["scenario_acceptance_summary"],
3270
3599
  selected_donor_summary=result["selected_donor_summary"],
3271
3600
  )
3601
+ result["capability_matrix_hits"] = matrix_hits
3602
+ result["unsupported_capabilities"] = coverage_gap.get("unsupported_capabilities", [])
3603
+ result["coverage_gaps"] = coverage_gap
3604
+ result["capability_parity"] = list(coverage_gap.get("capability_parity", []))
3605
+ result["blueprint_plan"] = blueprint_plan
3272
3606
  except PlanningError as exc:
3273
3607
  if result["donor_rejection_reasons"]:
3274
3608
  for item in result["donor_rejection_reasons"]:
@@ -3336,6 +3670,8 @@ def _explain_plan_payload(
3336
3670
  result["scenario_matrix_row"]["fixture_name"] = _scenario_fixture_name(result["scenario_matrix_row"].get("family"))
3337
3671
  result["scenario_matrix_row"]["matrix_version"] = "2.1"
3338
3672
  result["scenario_matrix_row"].update(_parity_counts(_capability_parity_entries(result.get("coverage_gaps", {}))))
3673
+ critical_capability_parity, _critical_parity_mismatches = _critical_capability_parity(dict(result.get("coverage_gaps") or {}))
3674
+ result["scenario_matrix_row"].update(_critical_parity_counts(critical_capability_parity))
3339
3675
  fixture_status, fixture_gaps = _fixture_expectation_status(result)
3340
3676
  result["fixture_registry_version"] = _load_fixture_corpus().get("fixture_registry_version", "unknown")
3341
3677
  result["fixture_expectation_status"] = fixture_status
@@ -3352,6 +3688,8 @@ def explain_plan(
3352
3688
  remote_provider: str = "github",
3353
3689
  import_cache_root: Path | None = None,
3354
3690
  max_remote_results: int = 10,
3691
+ remote_dry_run: bool = False,
3692
+ remote_audit_out: Path | None = None,
3355
3693
  acceptance_json_out: Path | None = None,
3356
3694
  ) -> None:
3357
3695
  result = _explain_plan_payload(
@@ -3362,6 +3700,8 @@ def explain_plan(
3362
3700
  remote_provider=remote_provider,
3363
3701
  import_cache_root=import_cache_root,
3364
3702
  max_remote_results=max_remote_results,
3703
+ remote_dry_run=remote_dry_run,
3704
+ remote_audit_out=remote_audit_out,
3365
3705
  )
3366
3706
  _write_json_artifact(result, acceptance_json_out)
3367
3707
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -3377,7 +3717,7 @@ def inventory_pkt(
3377
3717
  root = ET.fromstring(decode_pkt_modern(pkt_path.read_bytes()))
3378
3718
  payload = inventory_root(root)
3379
3719
  workspace = inspect_workspace_integrity(root)
3380
- donor_details = inspect_packet_tracer_compatibility_donor()
3720
+ donor_details = _inspect_packet_tracer_compatibility_donor_cached()
3381
3721
  compat_donor, compat_donor_version = donor_details.resolved_path, donor_details.donor_version
3382
3722
  payload["workspace_validation"] = {
3383
3723
  "workspace_mode": workspace.workspace_mode,
@@ -3552,6 +3892,8 @@ def parity_report(
3552
3892
  remote_provider: str = "github",
3553
3893
  import_cache_root: Path | None = None,
3554
3894
  max_remote_results: int = 10,
3895
+ remote_dry_run: bool = False,
3896
+ remote_audit_out: Path | None = None,
3555
3897
  acceptance_json_out: Path | None = None,
3556
3898
  ) -> None:
3557
3899
  payload = _explain_plan_payload(
@@ -3562,6 +3904,8 @@ def parity_report(
3562
3904
  remote_provider=remote_provider,
3563
3905
  import_cache_root=import_cache_root,
3564
3906
  max_remote_results=max_remote_results,
3907
+ remote_dry_run=remote_dry_run,
3908
+ remote_audit_out=remote_audit_out,
3565
3909
  )
3566
3910
  coverage_gap = dict(payload.get("coverage_gaps") or {})
3567
3911
  if "capability_parity" not in coverage_gap:
@@ -3574,6 +3918,7 @@ def parity_report(
3574
3918
  "critical_capability_parity": critical_capability_parity,
3575
3919
  "critical_parity_mismatches": critical_parity_mismatches,
3576
3920
  **_parity_counts(list(payload.get("capability_parity", []))),
3921
+ **_critical_parity_counts(critical_capability_parity),
3577
3922
  }
3578
3923
  _write_json_artifact(result, acceptance_json_out)
3579
3924
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -3588,6 +3933,8 @@ def compare_scenarios(
3588
3933
  remote_provider: str = "github",
3589
3934
  import_cache_root: Path | None = None,
3590
3935
  max_remote_results: int = 10,
3936
+ remote_dry_run: bool = False,
3937
+ remote_audit_out: Path | None = None,
3591
3938
  matrix_out: Path | None = None,
3592
3939
  acceptance_json_out: Path | None = None,
3593
3940
  ) -> None:
@@ -3602,6 +3949,8 @@ def compare_scenarios(
3602
3949
  remote_provider=remote_provider,
3603
3950
  import_cache_root=import_cache_root,
3604
3951
  max_remote_results=max_remote_results,
3952
+ remote_dry_run=remote_dry_run,
3953
+ remote_audit_out=remote_audit_out,
3605
3954
  )
3606
3955
  fixture_status, fixture_gaps = _fixture_expectation_status(payload)
3607
3956
  payloads.append(
@@ -3635,6 +3984,94 @@ def compare_scenarios(
3635
3984
  print(rendered)
3636
3985
 
3637
3986
 
3987
+ def feature_gap_report() -> None:
3988
+ print(json.dumps(build_feature_gap_report(), indent=2, ensure_ascii=False))
3989
+
3990
+
3991
+ LOCAL_AUDIT_PATTERNS = {
3992
+ "ospfv2": r"(?mi)^\s*router\s+ospf\s+\d+",
3993
+ "eigrp_ipv4": r"(?mi)^\s*router\s+eigrp\s+\d+",
3994
+ "ripv2": r"(?mi)^\s*router\s+rip\s*$",
3995
+ "static_route": r"(?mi)^\s*ip\s+route\s+\S+\s+\S+\s+\S+",
3996
+ "default_route": r"(?mi)^\s*ip\s+route\s+0\.0\.0\.0\s+0\.0\.0\.0\s+\S+",
3997
+ "dhcp_relay": r"(?mi)^\s*ip\s+helper-address\s+\d+\.\d+\.\d+\.\d+",
3998
+ "nat_static": r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+static\b",
3999
+ "nat_dynamic": r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+(?:list|route-map)\b",
4000
+ "pat": r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+list\s+\S+\s+interface\s+\S+\s+overload\b",
4001
+ "ssh_ios": r"(?mi)^\s*(?:ip\s+ssh|crypto\s+key\s+generate|ip\s+domain-name)\b",
4002
+ "ntp_ios": r"(?mi)^\s*ntp\s+server\s+\d+\.\d+\.\d+\.\d+",
4003
+ "syslog_ios": r"(?mi)^\s*logging\s+host\s+\d+\.\d+\.\d+\.\d+",
4004
+ "bgp": r"(?mi)^\s*router\s+bgp\s+\d+",
4005
+ "stp": r"(?mi)^\s*spanning-tree\b",
4006
+ "etherchannel": r"(?mi)^\s*(?:channel-group\s+\d+|interface\s+Port-channel)",
4007
+ "hsrp": r"(?mi)^\s*standby\s+\d+",
4008
+ "acl": r"(?mi)^\s*(?:access-list\s+\d+|ip\s+access-list\s+(?:standard|extended))\b",
4009
+ "dhcp_pool": r"(?mi)^\s*ip\s+dhcp\s+pool\b",
4010
+ }
4011
+
4012
+
4013
+ def build_local_sample_audit(root: Path) -> dict[str, object]:
4014
+ files = sorted([*root.rglob("*.pkt"), *root.rglob("*.pka")])
4015
+ capability_counts: Counter[str] = Counter()
4016
+ capability_examples: dict[str, list[str]] = defaultdict(list)
4017
+ device_counts: Counter[str] = Counter()
4018
+ decode_failures: list[dict[str, str]] = []
4019
+ for pkt_path in files:
4020
+ rel = str(pkt_path.relative_to(root))
4021
+ try:
4022
+ pkt_root = decode_pkt_to_root(pkt_path)
4023
+ except Exception as exc:
4024
+ decode_failures.append({"path": rel, "error_type": type(exc).__name__, "message": str(exc)[:200]})
4025
+ continue
4026
+ try:
4027
+ inventory = inventory_root(pkt_root)
4028
+ for device_type, count in inventory.get("topology_summary", {}).get("device_counts", {}).items():
4029
+ device_counts[str(device_type)] += int(count)
4030
+ except Exception:
4031
+ pass
4032
+ running = "\n".join(line.text or "" for line in pkt_root.findall(".//ENGINE/RUNNINGCONFIG/LINE"))
4033
+ for capability, pattern in LOCAL_AUDIT_PATTERNS.items():
4034
+ if re.search(pattern, running):
4035
+ capability_counts[capability] += 1
4036
+ if len(capability_examples[capability]) < 5:
4037
+ capability_examples[capability].append(rel)
4038
+ promotion_candidates = [
4039
+ {
4040
+ "capability": capability,
4041
+ "sample_count": count,
4042
+ "candidate_status": "local_evidence_only",
4043
+ "next_safe_action": "Add parser/inventory/editor roundtrip proof before public readiness promotion.",
4044
+ }
4045
+ for capability, count in capability_counts.most_common()
4046
+ ]
4047
+ return {
4048
+ "audit_version": "1.0",
4049
+ "source_root": str(root),
4050
+ "policy": "Local user-supplied Packet Tracer files are evidence inputs only; raw .pkt/.pka files are not public package content.",
4051
+ "total_files": len(files),
4052
+ "decode_success_count": len(files) - len(decode_failures),
4053
+ "decode_fail_count": len(decode_failures),
4054
+ "decode_failures": decode_failures[:20],
4055
+ "detected_config_capabilities": {
4056
+ capability: {
4057
+ "sample_count": count,
4058
+ "examples": capability_examples.get(capability, []),
4059
+ }
4060
+ for capability, count in capability_counts.most_common()
4061
+ },
4062
+ "top_device_types": [{"device_type": device_type, "count": count} for device_type, count in device_counts.most_common(30)],
4063
+ "promotion_candidates": promotion_candidates,
4064
+ }
4065
+
4066
+
4067
+ def local_sample_audit(root: Path, audit_out: Path | None = None) -> None:
4068
+ payload = build_local_sample_audit(root)
4069
+ target = audit_out or (Path("output") / "local-sample-audit.json")
4070
+ target.parent.mkdir(parents=True, exist_ok=True)
4071
+ target.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
4072
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
4073
+
4074
+
3638
4075
  def main() -> None:
3639
4076
  parser = argparse.ArgumentParser(description="Generate or inspect Cisco Packet Tracer 9.0 .pkt files")
3640
4077
  parser.add_argument("--blueprint", help="Path to the topology blueprint JSON")
@@ -3656,8 +4093,13 @@ def main() -> None:
3656
4093
  parser.add_argument("--remote-provider", default="github", help="Remote search provider name (default: github)")
3657
4094
  parser.add_argument("--import-cache-root", help="Local cache folder used for remote search auto-imports")
3658
4095
  parser.add_argument("--max-remote-results", type=int, default=10, help="Maximum number of remote search results to fetch/import")
4096
+ parser.add_argument("--remote-dry-run", action="store_true", help="Preview remote GitHub sample candidates and write an audit without downloading archives")
4097
+ parser.add_argument("--remote-audit-out", help="Optional JSON path for the local-only remote sample audit report")
3659
4098
  parser.add_argument("--blueprint-out", help="Optional JSON output path for the generated blueprint plan or refusal blueprint")
3660
4099
  parser.add_argument("--coverage-report", action="store_true", help="Print the aggregated capability coverage matrix")
4100
+ parser.add_argument("--feature-gap-report", action="store_true", help="Print the Packet Tracer feature gap atlas report")
4101
+ parser.add_argument("--local-sample-audit-root", help="Local user-supplied Packet Tracer sample folder to audit without importing raw files")
4102
+ parser.add_argument("--local-sample-audit-out", help="Optional JSON path for --local-sample-audit-root output")
3661
4103
  parser.add_argument("--inventory-capabilities", action="store_true", help="Include inferred capability inventory when using --inventory")
3662
4104
  parser.add_argument("--inventory-out", help="Optional JSON output path when using --inventory")
3663
4105
  parser.add_argument("--matrix-out", help="Optional JSON output path when using --compare-scenarios")
@@ -3669,6 +4111,7 @@ def main() -> None:
3669
4111
  reference_roots = [Path(path) for path in (args.reference_root or [])]
3670
4112
  donor_roots = [Path(path) for path in (args.donor_root or [])]
3671
4113
  import_cache_root = Path(args.import_cache_root) if args.import_cache_root else None
4114
+ remote_audit_out = Path(args.remote_audit_out) if args.remote_audit_out else None
3672
4115
 
3673
4116
  if args.explain_plan:
3674
4117
  explain_plan(
@@ -3679,6 +4122,8 @@ def main() -> None:
3679
4122
  remote_provider=args.remote_provider,
3680
4123
  import_cache_root=import_cache_root,
3681
4124
  max_remote_results=args.max_remote_results,
4125
+ remote_dry_run=args.remote_dry_run,
4126
+ remote_audit_out=remote_audit_out,
3682
4127
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
3683
4128
  )
3684
4129
  return
@@ -3691,6 +4136,8 @@ def main() -> None:
3691
4136
  remote_provider=args.remote_provider,
3692
4137
  import_cache_root=import_cache_root,
3693
4138
  max_remote_results=args.max_remote_results,
4139
+ remote_dry_run=args.remote_dry_run,
4140
+ remote_audit_out=remote_audit_out,
3694
4141
  matrix_out=Path(args.matrix_out) if args.matrix_out else None,
3695
4142
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
3696
4143
  )
@@ -3704,6 +4151,8 @@ def main() -> None:
3704
4151
  remote_provider=args.remote_provider,
3705
4152
  import_cache_root=import_cache_root,
3706
4153
  max_remote_results=args.max_remote_results,
4154
+ remote_dry_run=args.remote_dry_run,
4155
+ remote_audit_out=remote_audit_out,
3707
4156
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
3708
4157
  )
3709
4158
  return
@@ -3725,6 +4174,12 @@ def main() -> None:
3725
4174
  if args.coverage_report:
3726
4175
  coverage_report(reference_roots, donor_roots, device_family=args.device_family)
3727
4176
  return
4177
+ if args.feature_gap_report:
4178
+ feature_gap_report()
4179
+ return
4180
+ if args.local_sample_audit_root:
4181
+ local_sample_audit(Path(args.local_sample_audit_root), Path(args.local_sample_audit_out) if args.local_sample_audit_out else None)
4182
+ return
3728
4183
  if args.decode:
3729
4184
  if not args.xml_out:
3730
4185
  parser.error("--decode requires --xml-out")
@@ -3756,6 +4211,8 @@ def main() -> None:
3756
4211
  remote_provider=args.remote_provider,
3757
4212
  import_cache_root=import_cache_root,
3758
4213
  max_remote_results=args.max_remote_results,
4214
+ remote_dry_run=args.remote_dry_run,
4215
+ remote_audit_out=remote_audit_out,
3759
4216
  blueprint_out_path=Path(args.blueprint_out) if args.blueprint_out else None,
3760
4217
  )
3761
4218
  except PlanningError as exc: