packet-tracer-skill 0.2.2 → 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.
@@ -28,8 +28,62 @@ EDITOR_TEST_ALIASES = {
28
28
  "netflow": ["set_netflow", "netflow destination"],
29
29
  "span": ["set_span", "monitor session"],
30
30
  "port_security": ["set_port_security", "port-security"],
31
+ "dot1x": ["set_dot1x", "dot1x system-auth-control", "authentication port-control"],
32
+ "qos": ["set_qos_policy", "class-map", "policy-map", "service-policy"],
33
+ "bgp": ["set_bgp_neighbor", "router bgp", "remote-as"],
34
+ "stp": ["set_stp", "spanning-tree mode"],
35
+ "rstp": ["set_stp", "rapid-pvst"],
36
+ "etherchannel": ["set_etherchannel", "channel-group", "Port-channel"],
37
+ "lacp": ["set_etherchannel", "mode active"],
38
+ "pagp": ["set_etherchannel", "mode desirable"],
39
+ "vtp": ["set_vtp", "vtp domain"],
40
+ "dtp": ["set_dtp", "dynamic desirable"],
31
41
  "wep": ["security wep", "WEP_KEY"],
32
42
  "wpa_enterprise": ["wpa-enterprise", "wpa2-enterprise", "802.1x", "radius_server"],
43
+ "gre": ["set_gre_tunnel", "gre tunnel", "tunnel mode gre ip"],
44
+ "ppp": ["set_ppp_interface", "encapsulation ppp"],
45
+ "ipsec": ["set_ipsec_transform_set", "crypto ipsec transform-set", "set_crypto_map"],
46
+ "vpn": ["set_crypto_map", "crypto map"],
47
+ "cbac": ["set_cbac_inspect", "ip inspect name"],
48
+ "zfw": ["set_zfw_zone_interface", "set_zfw_zone_pair", "set_zfw_policy", "zone-pair security", "policy-map type inspect"],
49
+ "voip": ["set_telephony_service", "set_dial_peer_voice", "telephony-service", "dial-peer voice"],
50
+ "ip_phone": ["set_ephone_dn", "set_ephone", "ephone-dn", "ephone"],
51
+ "call_manager": ["set_telephony_service", "set_ephone_dn", "telephony-service"],
52
+ "real_http": ["set_script_file_content", "realhttp", "real http"],
53
+ "real_websocket": ["set_script_file_content", "realws", "websocket"],
54
+ "python_programming": ["set_script_file_content", "main.py", "python"],
55
+ "javascript_programming": ["set_script_file_content", "main.js", "javascript"],
56
+ "tcp_udp_app": ["set_script_file_content", "tcpServer.js", "udpSend.js", "tcp_udp_app"],
57
+ "ospfv2": ["set_ospfv2_network", "router ospf"],
58
+ "eigrp_ipv4": ["set_eigrp_ipv4_network", "router eigrp"],
59
+ "ripv2": ["set_ripv2_network", "router rip"],
60
+ "static_route": ["set_static_route", "ip route"],
61
+ "default_route": ["set_static_route", "0.0.0.0"],
62
+ "dhcp_relay": ["set_dhcp_relay", "ip helper-address"],
63
+ "nat_static": ["set_nat_static", "ip nat inside source static"],
64
+ "nat_dynamic": ["set_nat_interface", "ip nat inside"],
65
+ "pat": ["set_pat_overload", "overload"],
66
+ "ssh_ios": ["set_ssh_ios", "ip ssh"],
67
+ "ntp_ios": ["set_ntp_server", "ntp server"],
68
+ "syslog_ios": ["set_syslog_server", "logging host"],
69
+ }
70
+ DONOR_BACKED_READY_PROOF_CAPABILITIES = {
71
+ "ospfv3",
72
+ "eigrp_ipv6",
73
+ "ripng",
74
+ "hsrp",
75
+ "dot1x",
76
+ "qos",
77
+ "zfw",
78
+ "cbac",
79
+ "voip",
80
+ "ip_phone",
81
+ "call_manager",
82
+ "real_http",
83
+ "real_websocket",
84
+ "python_programming",
85
+ "javascript_programming",
86
+ "tcp_udp_app",
33
87
  }
34
88
 
35
89
  STATUS_ORDER = [
@@ -111,7 +165,9 @@ def _editor_test_mentions(capability: str) -> bool:
111
165
  def _feature_status(feature: dict[str, Any], evidence: dict[str, Any]) -> str:
112
166
  capability = str(feature.get("capability") or feature.get("id") or "")
113
167
  ceiling = str(feature.get("support_ceiling") or "report_supported")
114
- if evidence["editor_roundtrip_test"]:
168
+ if evidence.get("donor_backed_proof_ready"):
169
+ raw_status = "donor_backed_ready"
170
+ elif evidence["editor_roundtrip_test"]:
115
171
  raw_status = "edit_proven"
116
172
  elif evidence["parser_pattern"] and (evidence["sample_count"] or evidence["catalog_keyword"] or evidence["coverage_provider"]):
117
173
  raw_status = "report_supported"
@@ -164,12 +220,20 @@ def build_feature_gap_report(
164
220
  "catalog_keyword": capability in CAPABILITY_KEYWORDS,
165
221
  "coverage_provider": capability in CAPABILITY_PROVIDER_FAMILIES,
166
222
  "editor_roundtrip_test": _editor_test_mentions(capability),
223
+ "donor_backed_proof": bool(feature.get("donor_backed_proof"))
224
+ and capability in DONOR_BACKED_READY_PROOF_CAPABILITIES,
167
225
  "sample_count": len(hits),
168
226
  "sample_examples": hits[:5],
169
227
  "sample_path_count": len(hits),
170
228
  "decode_verified_sample_count": len(decoded_hits),
171
229
  "decode_verified_examples": decoded_hits[:5],
172
230
  }
231
+ evidence["donor_backed_proof_ready"] = bool(
232
+ evidence["donor_backed_proof"]
233
+ and evidence["editor_roundtrip_test"]
234
+ and evidence["sample_count"]
235
+ and evidence["decode_verified_sample_count"]
236
+ )
173
237
  status = _feature_status(feature, evidence)
174
238
  if status != "not_mapped":
175
239
  mapped_features += 1
@@ -4,6 +4,7 @@ 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
8
9
  from functools import lru_cache
9
10
  import json
@@ -37,7 +38,13 @@ from pkt_builder import build_packet_tracer_xml
37
38
  from pkt_codec import decode_pkt_file, decode_pkt_modern, encode_pkt_modern
38
39
  from pkt_editor import apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
39
40
  from pkt_transformer import transform_from_blueprint
40
- 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
+ )
41
48
  from sample_catalog import ReferencePattern, SampleCandidate, SampleDescriptor, load_catalog, load_curated_donor_catalog, load_reference_catalog, summarize_pkt_descriptor
42
49
  from sample_selector import rank_curated_donor_samples, rank_reference_samples, rank_samples, select_best_sample
43
50
  from workspace_repair import inspect_donor_coherence, inspect_workspace_integrity, validate_donor_coherence, validate_workspace_integrity
@@ -83,6 +90,9 @@ DEVICE_FAMILY_MAP = {
83
90
  "Tablet": "end devices",
84
91
  "Smartphone": "end devices",
85
92
  "Printer": "end devices",
93
+ "IpPhone": "end devices",
94
+ "HomeVoip": "end devices",
95
+ "AnalogPhone": "end devices",
86
96
  "LightWeightAccessPoint": "access points",
87
97
  "WirelessRouter": "home/wireless routers",
88
98
  "WirelessRouterNewGeneration": "home/wireless routers",
@@ -251,6 +261,8 @@ def _resolve_remote_sources(
251
261
  remote_provider: str = "github",
252
262
  import_cache_root: Path | None = None,
253
263
  max_remote_results: int = 10,
264
+ remote_dry_run: bool = False,
265
+ remote_audit_out: Path | None = None,
254
266
  ) -> tuple[list[Path], list[Path], list[dict[str, object]]]:
255
267
  resolved_reference_roots = list(reference_roots or [])
256
268
  resolved_donor_roots = list(donor_roots or [])
@@ -258,13 +270,19 @@ def _resolve_remote_sources(
258
270
  return resolved_reference_roots, resolved_donor_roots, []
259
271
  remote_candidates = search_remote_candidates(plan, provider=remote_provider, max_results=max_remote_results)
260
272
  cache_root = import_cache_root or _default_import_cache_root()
261
- 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)
262
280
  for candidate in imported_candidates:
263
281
  if candidate.path:
264
282
  imported_root = Path(candidate.path)
265
283
  if imported_root not in resolved_reference_roots:
266
284
  resolved_reference_roots.append(imported_root)
267
- if imported_root not in resolved_donor_roots:
285
+ if candidate_is_curated_eligible(candidate) and imported_root not in resolved_donor_roots:
268
286
  resolved_donor_roots.append(imported_root)
269
287
  return resolved_reference_roots, resolved_donor_roots, remote_asdict_list(imported_candidates)
270
288
 
@@ -364,6 +382,7 @@ def _preferred_donor_archetypes_for_plan(plan: IntentPlan, topology_tags: list[s
364
382
  or bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "multilayer_switching", "security_edge"})
365
383
  )
366
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"})
367
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"})
368
387
  wireless_advanced_signal = plan.network_style == "wireless_advanced" or bool(capabilities & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"})
369
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"})
@@ -393,6 +412,8 @@ def _preferred_donor_archetypes_for_plan(plan: IntentPlan, topology_tags: list[s
393
412
  family_hints.append("L2 security/monitoring")
394
413
  elif ipv6_routing_signal:
395
414
  family_hints.append("IPv6/routing")
415
+ elif ipv4_routing_management_signal:
416
+ family_hints.append("IPv4 routing/management")
396
417
  elif wan_signal:
397
418
  family_hints.append("WAN/security edge")
398
419
  if campus_signal and not bool(capabilities & {"vpn", "ipsec", "gre", "ppp", "security_edge"}):
@@ -447,6 +468,8 @@ def _required_runtime_features_for_plan(plan: IntentPlan) -> list[str]:
447
468
  features.add("multilayer_runtime")
448
469
  if capabilities & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
449
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")
450
473
  return sorted(features)
451
474
 
452
475
 
@@ -789,12 +812,23 @@ def _critical_capability_parity(coverage_gap: dict[str, object]) -> tuple[list[d
789
812
  def _parity_counts(parity_entries: list[dict[str, object]]) -> dict[str, int]:
790
813
  return {
791
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"))),
792
816
  "parity_generate_ready_count": sum(1 for entry in parity_entries if bool(entry.get("generate_supported"))),
793
817
  "parity_acceptance_verified_count": sum(1 for entry in parity_entries if bool(entry.get("acceptance_verified"))),
794
818
  "parity_mismatch_count": sum(1 for entry in parity_entries if entry.get("generate_mismatch_reason")),
795
819
  }
796
820
 
797
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
+
798
832
  def _selected_donor_summary(
799
833
  diagnostics: list[dict[str, object]],
800
834
  donor_archetype: DonorArchetypePlan | None = None,
@@ -2988,6 +3022,7 @@ def _scenario_acceptance_summary(
2988
3022
  "missing_critical_capabilities": [str(item) for item in list(readiness.get("missing_critical_capabilities", [])) if str(item).strip()],
2989
3023
  "critical_capability_parity": critical_capability_parity,
2990
3024
  "critical_parity_mismatches": critical_parity_mismatches,
3025
+ **_critical_parity_counts(critical_capability_parity),
2991
3026
  "key_reasons": key_reasons[:3],
2992
3027
  "next_best_action": next_best_action,
2993
3028
  "remediation_steps": remediation_steps,
@@ -3216,6 +3251,8 @@ def generate_from_prompt(
3216
3251
  remote_provider: str = "github",
3217
3252
  import_cache_root: Path | None = None,
3218
3253
  max_remote_results: int = 10,
3254
+ remote_dry_run: bool = False,
3255
+ remote_audit_out: Path | None = None,
3219
3256
  blueprint_out_path: Path | None = None,
3220
3257
  ) -> None:
3221
3258
  raw_plan = parse_intent(prompt)
@@ -3227,6 +3264,8 @@ def generate_from_prompt(
3227
3264
  remote_provider=remote_provider,
3228
3265
  import_cache_root=import_cache_root,
3229
3266
  max_remote_results=max_remote_results,
3267
+ remote_dry_run=remote_dry_run,
3268
+ remote_audit_out=remote_audit_out,
3230
3269
  )
3231
3270
  raw_plan.remote_search_results = remote_results
3232
3271
  if raw_plan.goal == "edit" and raw_plan.pkt_path:
@@ -3343,6 +3382,8 @@ def _explain_plan_payload(
3343
3382
  remote_provider: str = "github",
3344
3383
  import_cache_root: Path | None = None,
3345
3384
  max_remote_results: int = 10,
3385
+ remote_dry_run: bool = False,
3386
+ remote_audit_out: Path | None = None,
3346
3387
  ) -> dict[str, object]:
3347
3388
  raw_plan = parse_intent(prompt)
3348
3389
  resolved_reference_roots, resolved_donor_roots, remote_results = _resolve_remote_sources(
@@ -3353,6 +3394,8 @@ def _explain_plan_payload(
3353
3394
  remote_provider=remote_provider,
3354
3395
  import_cache_root=import_cache_root,
3355
3396
  max_remote_results=max_remote_results,
3397
+ remote_dry_run=remote_dry_run,
3398
+ remote_audit_out=remote_audit_out,
3356
3399
  )
3357
3400
  plan = _apply_prompt_compatibility_requirements(raw_plan, resolved_donor_roots)
3358
3401
  plan.remote_search_results = remote_results
@@ -3627,6 +3670,8 @@ def _explain_plan_payload(
3627
3670
  result["scenario_matrix_row"]["fixture_name"] = _scenario_fixture_name(result["scenario_matrix_row"].get("family"))
3628
3671
  result["scenario_matrix_row"]["matrix_version"] = "2.1"
3629
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))
3630
3675
  fixture_status, fixture_gaps = _fixture_expectation_status(result)
3631
3676
  result["fixture_registry_version"] = _load_fixture_corpus().get("fixture_registry_version", "unknown")
3632
3677
  result["fixture_expectation_status"] = fixture_status
@@ -3643,6 +3688,8 @@ def explain_plan(
3643
3688
  remote_provider: str = "github",
3644
3689
  import_cache_root: Path | None = None,
3645
3690
  max_remote_results: int = 10,
3691
+ remote_dry_run: bool = False,
3692
+ remote_audit_out: Path | None = None,
3646
3693
  acceptance_json_out: Path | None = None,
3647
3694
  ) -> None:
3648
3695
  result = _explain_plan_payload(
@@ -3653,6 +3700,8 @@ def explain_plan(
3653
3700
  remote_provider=remote_provider,
3654
3701
  import_cache_root=import_cache_root,
3655
3702
  max_remote_results=max_remote_results,
3703
+ remote_dry_run=remote_dry_run,
3704
+ remote_audit_out=remote_audit_out,
3656
3705
  )
3657
3706
  _write_json_artifact(result, acceptance_json_out)
3658
3707
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -3843,6 +3892,8 @@ def parity_report(
3843
3892
  remote_provider: str = "github",
3844
3893
  import_cache_root: Path | None = None,
3845
3894
  max_remote_results: int = 10,
3895
+ remote_dry_run: bool = False,
3896
+ remote_audit_out: Path | None = None,
3846
3897
  acceptance_json_out: Path | None = None,
3847
3898
  ) -> None:
3848
3899
  payload = _explain_plan_payload(
@@ -3853,6 +3904,8 @@ def parity_report(
3853
3904
  remote_provider=remote_provider,
3854
3905
  import_cache_root=import_cache_root,
3855
3906
  max_remote_results=max_remote_results,
3907
+ remote_dry_run=remote_dry_run,
3908
+ remote_audit_out=remote_audit_out,
3856
3909
  )
3857
3910
  coverage_gap = dict(payload.get("coverage_gaps") or {})
3858
3911
  if "capability_parity" not in coverage_gap:
@@ -3865,6 +3918,7 @@ def parity_report(
3865
3918
  "critical_capability_parity": critical_capability_parity,
3866
3919
  "critical_parity_mismatches": critical_parity_mismatches,
3867
3920
  **_parity_counts(list(payload.get("capability_parity", []))),
3921
+ **_critical_parity_counts(critical_capability_parity),
3868
3922
  }
3869
3923
  _write_json_artifact(result, acceptance_json_out)
3870
3924
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -3879,6 +3933,8 @@ def compare_scenarios(
3879
3933
  remote_provider: str = "github",
3880
3934
  import_cache_root: Path | None = None,
3881
3935
  max_remote_results: int = 10,
3936
+ remote_dry_run: bool = False,
3937
+ remote_audit_out: Path | None = None,
3882
3938
  matrix_out: Path | None = None,
3883
3939
  acceptance_json_out: Path | None = None,
3884
3940
  ) -> None:
@@ -3893,6 +3949,8 @@ def compare_scenarios(
3893
3949
  remote_provider=remote_provider,
3894
3950
  import_cache_root=import_cache_root,
3895
3951
  max_remote_results=max_remote_results,
3952
+ remote_dry_run=remote_dry_run,
3953
+ remote_audit_out=remote_audit_out,
3896
3954
  )
3897
3955
  fixture_status, fixture_gaps = _fixture_expectation_status(payload)
3898
3956
  payloads.append(
@@ -3930,6 +3988,90 @@ def feature_gap_report() -> None:
3930
3988
  print(json.dumps(build_feature_gap_report(), indent=2, ensure_ascii=False))
3931
3989
 
3932
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
+
3933
4075
  def main() -> None:
3934
4076
  parser = argparse.ArgumentParser(description="Generate or inspect Cisco Packet Tracer 9.0 .pkt files")
3935
4077
  parser.add_argument("--blueprint", help="Path to the topology blueprint JSON")
@@ -3951,9 +4093,13 @@ def main() -> None:
3951
4093
  parser.add_argument("--remote-provider", default="github", help="Remote search provider name (default: github)")
3952
4094
  parser.add_argument("--import-cache-root", help="Local cache folder used for remote search auto-imports")
3953
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")
3954
4098
  parser.add_argument("--blueprint-out", help="Optional JSON output path for the generated blueprint plan or refusal blueprint")
3955
4099
  parser.add_argument("--coverage-report", action="store_true", help="Print the aggregated capability coverage matrix")
3956
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")
3957
4103
  parser.add_argument("--inventory-capabilities", action="store_true", help="Include inferred capability inventory when using --inventory")
3958
4104
  parser.add_argument("--inventory-out", help="Optional JSON output path when using --inventory")
3959
4105
  parser.add_argument("--matrix-out", help="Optional JSON output path when using --compare-scenarios")
@@ -3965,6 +4111,7 @@ def main() -> None:
3965
4111
  reference_roots = [Path(path) for path in (args.reference_root or [])]
3966
4112
  donor_roots = [Path(path) for path in (args.donor_root or [])]
3967
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
3968
4115
 
3969
4116
  if args.explain_plan:
3970
4117
  explain_plan(
@@ -3975,6 +4122,8 @@ def main() -> None:
3975
4122
  remote_provider=args.remote_provider,
3976
4123
  import_cache_root=import_cache_root,
3977
4124
  max_remote_results=args.max_remote_results,
4125
+ remote_dry_run=args.remote_dry_run,
4126
+ remote_audit_out=remote_audit_out,
3978
4127
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
3979
4128
  )
3980
4129
  return
@@ -3987,6 +4136,8 @@ def main() -> None:
3987
4136
  remote_provider=args.remote_provider,
3988
4137
  import_cache_root=import_cache_root,
3989
4138
  max_remote_results=args.max_remote_results,
4139
+ remote_dry_run=args.remote_dry_run,
4140
+ remote_audit_out=remote_audit_out,
3990
4141
  matrix_out=Path(args.matrix_out) if args.matrix_out else None,
3991
4142
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
3992
4143
  )
@@ -4000,6 +4151,8 @@ def main() -> None:
4000
4151
  remote_provider=args.remote_provider,
4001
4152
  import_cache_root=import_cache_root,
4002
4153
  max_remote_results=args.max_remote_results,
4154
+ remote_dry_run=args.remote_dry_run,
4155
+ remote_audit_out=remote_audit_out,
4003
4156
  acceptance_json_out=Path(args.acceptance_json_out) if args.acceptance_json_out else None,
4004
4157
  )
4005
4158
  return
@@ -4024,6 +4177,9 @@ def main() -> None:
4024
4177
  if args.feature_gap_report:
4025
4178
  feature_gap_report()
4026
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
4027
4183
  if args.decode:
4028
4184
  if not args.xml_out:
4029
4185
  parser.error("--decode requires --xml-out")
@@ -4055,6 +4211,8 @@ def main() -> None:
4055
4211
  remote_provider=args.remote_provider,
4056
4212
  import_cache_root=import_cache_root,
4057
4213
  max_remote_results=args.max_remote_results,
4214
+ remote_dry_run=args.remote_dry_run,
4215
+ remote_audit_out=remote_audit_out,
4058
4216
  blueprint_out_path=Path(args.blueprint_out) if args.blueprint_out else None,
4059
4217
  )
4060
4218
  except PlanningError as exc: