tirtc-device-builder 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +44 -14
  4. package/bin/tirtc-device-builder.js +33 -1
  5. package/package.json +1 -1
  6. package/skills/tirtc-esp32-builder/SKILL.md +139 -39
  7. package/skills/tirtc-esp32-builder/USAGE.md +3 -2
  8. package/skills/tirtc-esp32-builder/VERSION +1 -1
  9. package/skills/tirtc-esp32-builder/agents/openai.yaml +1 -1
  10. package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +4 -0
  11. package/skills/tirtc-esp32-builder/assets/board-identity.example.json +16 -0
  12. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +3 -3
  13. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +9 -1
  14. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +5 -5
  15. package/skills/tirtc-esp32-builder/assets/report-template.md +6 -1
  16. package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +13 -0
  17. package/skills/tirtc-esp32-builder/knowledge/board-registry.json +4 -0
  18. package/skills/tirtc-esp32-builder/references/audio-contract.md +33 -3
  19. package/skills/tirtc-esp32-builder/references/board-knowledge.md +72 -0
  20. package/skills/tirtc-esp32-builder/references/capability-rules.md +11 -1
  21. package/skills/tirtc-esp32-builder/references/environment.md +20 -0
  22. package/skills/tirtc-esp32-builder/references/hardware-ir.md +17 -3
  23. package/skills/tirtc-esp32-builder/references/porting-risks.md +25 -0
  24. package/skills/tirtc-esp32-builder/references/reporting.md +1 -1
  25. package/skills/tirtc-esp32-builder/references/runtime-contract.md +12 -2
  26. package/skills/tirtc-esp32-builder/references/tirtc-platform.md +92 -0
  27. package/skills/tirtc-esp32-builder/references/workflow.md +31 -11
  28. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +105 -3
  29. package/skills/tirtc-esp32-builder/scripts/board_registry.py +677 -0
  30. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +147 -8
  31. package/skills/tirtc-esp32-builder/scripts/project_portability.py +178 -3
  32. package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +122 -1
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and query reusable ESP32 board knowledge without guessing identity."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from hardware_ir import load_ir, validate_ir, validate_source_locations
15
+
16
+
17
+ SCHEMA_VERSION = 1
18
+ PACKAGE_STATUSES = {"knowledge_only", "adapter_verified", "hil_verified"}
19
+ LESSON_SCOPES = {"generic", "component", "board"}
20
+ LESSON_VERIFICATION = {
21
+ "reported",
22
+ "corroborated",
23
+ "build_verified",
24
+ "hardware_verified",
25
+ "hil_verified",
26
+ }
27
+ ACTION_KINDS = {"prefer", "require", "warn", "block_until_hil"}
28
+ SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
29
+
30
+
31
+ def load_json(path: Path, label: str) -> dict[str, Any]:
32
+ try:
33
+ value = json.loads(path.read_text(encoding="utf-8"))
34
+ except FileNotFoundError as exc:
35
+ raise ValueError(f"{label} does not exist: {path}") from exc
36
+ except json.JSONDecodeError as exc:
37
+ raise ValueError(
38
+ f"invalid {label} JSON at line {exc.lineno}: {exc.msg}"
39
+ ) from exc
40
+ if not isinstance(value, dict):
41
+ raise ValueError(f"{label} root must be an object")
42
+ return value
43
+
44
+
45
+ def normalized(value: Any) -> str:
46
+ return str(value).strip().casefold() if value is not None else ""
47
+
48
+
49
+ def nonempty(value: Any, path: str, errors: list[str]) -> bool:
50
+ if not isinstance(value, str) or not value.strip():
51
+ errors.append(f"{path} must be a non-empty string")
52
+ return False
53
+ return True
54
+
55
+
56
+ def nullable_nonempty(value: Any, path: str, errors: list[str]) -> None:
57
+ if value is not None:
58
+ nonempty(value, path, errors)
59
+
60
+
61
+ def nullable_positive_int(value: Any, path: str, errors: list[str]) -> None:
62
+ if value is not None and (
63
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
64
+ ):
65
+ errors.append(f"{path} must be a positive integer or null")
66
+
67
+
68
+ def string_array(value: Any, path: str, errors: list[str]) -> list[str]:
69
+ if not isinstance(value, list):
70
+ errors.append(f"{path} must be an array")
71
+ return []
72
+ result: list[str] = []
73
+ for index, item in enumerate(value):
74
+ if nonempty(item, f"{path}[{index}]", errors):
75
+ result.append(item)
76
+ return result
77
+
78
+
79
+ def validate_component(item: Any, path: str, errors: list[str]) -> None:
80
+ if not isinstance(item, dict):
81
+ errors.append(f"{path} must be an object")
82
+ return
83
+ nonempty(item.get("kind"), f"{path}.kind", errors)
84
+ nonempty(item.get("model"), f"{path}.model", errors)
85
+
86
+
87
+ def validate_probe(item: Any, path: str, errors: list[str]) -> None:
88
+ if not isinstance(item, dict):
89
+ errors.append(f"{path} must be an object")
90
+ return
91
+ nonempty(item.get("kind"), f"{path}.kind", errors)
92
+ nonempty(item.get("value"), f"{path}.value", errors)
93
+ required = item.get("required_for_exact")
94
+ if not isinstance(required, bool):
95
+ errors.append(f"{path}.required_for_exact must be true or false")
96
+
97
+
98
+ def validate_identity(identity: Any, path: str, errors: list[str]) -> None:
99
+ if not isinstance(identity, dict):
100
+ errors.append(f"{path} must be an object")
101
+ return
102
+ for field in ("vendor", "model", "hardware_revision", "target"):
103
+ nonempty(identity.get(field), f"{path}.{field}", errors)
104
+ aliases = string_array(identity.get("aliases", []), f"{path}.aliases", errors)
105
+ names = [identity.get("model"), *aliases]
106
+ normalized_names = [normalized(value) for value in names if normalized(value)]
107
+ if len(normalized_names) != len(set(normalized_names)):
108
+ errors.append(f"{path}.model and aliases must be unique")
109
+ nullable_nonempty(identity.get("module"), f"{path}.module", errors)
110
+ nullable_positive_int(identity.get("flash_mb"), f"{path}.flash_mb", errors)
111
+ nullable_positive_int(identity.get("psram_mb"), f"{path}.psram_mb", errors)
112
+ components = identity.get("components", [])
113
+ if not isinstance(components, list):
114
+ errors.append(f"{path}.components must be an array")
115
+ else:
116
+ for index, item in enumerate(components):
117
+ validate_component(item, f"{path}.components[{index}]", errors)
118
+ probes = identity.get("probes", [])
119
+ if not isinstance(probes, list):
120
+ errors.append(f"{path}.probes must be an array")
121
+ else:
122
+ for index, item in enumerate(probes):
123
+ validate_probe(item, f"{path}.probes[{index}]", errors)
124
+
125
+
126
+ def safe_registry_path(registry_path: Path, value: Any, path: str, errors: list[str]) -> Path | None:
127
+ if not isinstance(value, str) or not value.strip():
128
+ errors.append(f"{path} must be a non-empty registry-relative path")
129
+ return None
130
+ relative = Path(value)
131
+ if relative.is_absolute():
132
+ errors.append(f"{path} must be registry-relative")
133
+ return None
134
+ root = registry_path.parent.resolve()
135
+ resolved = (root / relative).resolve()
136
+ if resolved != root and root not in resolved.parents:
137
+ errors.append(f"{path} escapes the registry root")
138
+ return None
139
+ return resolved
140
+
141
+
142
+ def validate_lesson(
143
+ lesson: Any,
144
+ path: str,
145
+ package_id: str,
146
+ board_ids: set[str],
147
+ registry_path: Path,
148
+ errors: list[str],
149
+ ) -> None:
150
+ if not isinstance(lesson, dict):
151
+ errors.append(f"{path} must be an object")
152
+ return
153
+ nonempty(lesson.get("id"), f"{path}.id", errors)
154
+ scope = lesson.get("scope")
155
+ if scope not in LESSON_SCOPES:
156
+ errors.append(f"{path}.scope must be one of {', '.join(sorted(LESSON_SCOPES))}")
157
+ nonempty(lesson.get("subject"), f"{path}.subject", errors)
158
+ nonempty(lesson.get("guidance"), f"{path}.guidance", errors)
159
+ action = lesson.get("action")
160
+ if action not in ACTION_KINDS:
161
+ errors.append(f"{path}.action must be one of {', '.join(sorted(ACTION_KINDS))}")
162
+ verification = lesson.get("verification")
163
+ if verification not in LESSON_VERIFICATION:
164
+ errors.append(
165
+ f"{path}.verification must be one of "
166
+ + ", ".join(sorted(LESSON_VERIFICATION))
167
+ )
168
+ applies_to = lesson.get("applies_to")
169
+ if not isinstance(applies_to, dict):
170
+ errors.append(f"{path}.applies_to must be an object")
171
+ applies_to = {}
172
+ if scope == "board" and applies_to.get("package_id") != package_id:
173
+ errors.append(f"{path} board lesson must target its containing package")
174
+ if scope == "component":
175
+ nonempty(applies_to.get("kind"), f"{path}.applies_to.kind", errors)
176
+ nonempty(applies_to.get("model"), f"{path}.applies_to.model", errors)
177
+
178
+ evidence = lesson.get("evidence")
179
+ if not isinstance(evidence, list) or not evidence:
180
+ errors.append(f"{path}.evidence must be a non-empty array")
181
+ evidence = []
182
+ evidence_boards: set[str] = set()
183
+ for index, item in enumerate(evidence):
184
+ prefix = f"{path}.evidence[{index}]"
185
+ if not isinstance(item, dict):
186
+ errors.append(f"{prefix} must be an object")
187
+ continue
188
+ board_id = item.get("board_package_id")
189
+ if nonempty(board_id, f"{prefix}.board_package_id", errors):
190
+ evidence_boards.add(board_id)
191
+ if board_id not in board_ids:
192
+ errors.append(f"{prefix}.board_package_id is not registered")
193
+ nullable_nonempty(item.get("source_ref"), f"{prefix}.source_ref", errors)
194
+ sha = item.get("artifact_sha256")
195
+ if sha is not None and (not isinstance(sha, str) or SHA256_RE.fullmatch(sha) is None):
196
+ errors.append(f"{prefix}.artifact_sha256 must be a full SHA-256")
197
+
198
+ if verification in {"hardware_verified", "hil_verified"} and not any(
199
+ isinstance(item, dict) and item.get("artifact_sha256")
200
+ for item in evidence
201
+ ):
202
+ errors.append(f"{path} hardware/HIL lesson requires artifact SHA-256 evidence")
203
+ if scope == "generic":
204
+ if len(evidence_boards) < 2:
205
+ errors.append(f"{path} generic lesson requires two independent board packages")
206
+ regression_test = safe_registry_path(
207
+ registry_path,
208
+ lesson.get("regression_test"),
209
+ f"{path}.regression_test",
210
+ errors,
211
+ )
212
+ if regression_test is not None and not regression_test.is_file():
213
+ errors.append(f"{path}.regression_test does not exist")
214
+
215
+
216
+ def validate_registry(data: dict[str, Any], registry_path: Path) -> list[str]:
217
+ errors: list[str] = []
218
+ if data.get("schema_version") != SCHEMA_VERSION:
219
+ errors.append(f"schema_version must be {SCHEMA_VERSION}")
220
+ boards = data.get("boards")
221
+ if not isinstance(boards, list):
222
+ errors.append("boards must be an array")
223
+ return errors
224
+ package_ids: set[str] = set()
225
+ for index, item in enumerate(boards):
226
+ prefix = f"boards[{index}]"
227
+ if not isinstance(item, dict):
228
+ errors.append(f"{prefix} must be an object")
229
+ continue
230
+ package_id = item.get("package_id")
231
+ if nonempty(package_id, f"{prefix}.package_id", errors):
232
+ if package_id in package_ids:
233
+ errors.append(f"duplicate package_id {package_id!r}")
234
+ package_ids.add(package_id)
235
+
236
+ lesson_ids: set[str] = set()
237
+ for index, board in enumerate(boards):
238
+ if not isinstance(board, dict):
239
+ continue
240
+ prefix = f"boards[{index}]"
241
+ package_id = str(board.get("package_id", ""))
242
+ status = board.get("status")
243
+ if status not in PACKAGE_STATUSES:
244
+ errors.append(
245
+ f"{prefix}.status must be one of {', '.join(sorted(PACKAGE_STATUSES))}"
246
+ )
247
+ validate_identity(board.get("identity"), f"{prefix}.identity", errors)
248
+ compatibility = board.get("compatibility")
249
+ if not isinstance(compatibility, dict):
250
+ errors.append(f"{prefix}.compatibility must be an object")
251
+ else:
252
+ nonempty(compatibility.get("idf"), f"{prefix}.compatibility.idf", errors)
253
+ nonempty(
254
+ compatibility.get("tirtc_sdk"),
255
+ f"{prefix}.compatibility.tirtc_sdk",
256
+ errors,
257
+ )
258
+
259
+ artifacts = board.get("artifacts", {})
260
+ if not isinstance(artifacts, dict):
261
+ errors.append(f"{prefix}.artifacts must be an object")
262
+ artifacts = {}
263
+ if status in {"adapter_verified", "hil_verified"}:
264
+ for field in ("hardware_ir", "adapter", "config_overlay"):
265
+ resolved = safe_registry_path(
266
+ registry_path,
267
+ artifacts.get(field),
268
+ f"{prefix}.artifacts.{field}",
269
+ errors,
270
+ )
271
+ if resolved is not None and not resolved.exists():
272
+ errors.append(f"{prefix}.artifacts.{field} does not exist")
273
+ contracts = artifacts.get("contracts")
274
+ if not isinstance(contracts, list) or not contracts:
275
+ errors.append(f"{prefix}.artifacts.contracts must be a non-empty array")
276
+ else:
277
+ for contract_index, value in enumerate(contracts):
278
+ resolved = safe_registry_path(
279
+ registry_path,
280
+ value,
281
+ f"{prefix}.artifacts.contracts[{contract_index}]",
282
+ errors,
283
+ )
284
+ if resolved is not None and not resolved.is_file():
285
+ errors.append(
286
+ f"{prefix}.artifacts.contracts[{contract_index}] does not exist"
287
+ )
288
+
289
+ lessons = board.get("lessons", [])
290
+ if not isinstance(lessons, list):
291
+ errors.append(f"{prefix}.lessons must be an array")
292
+ continue
293
+ for lesson_index, lesson in enumerate(lessons):
294
+ lesson_path = f"{prefix}.lessons[{lesson_index}]"
295
+ validate_lesson(
296
+ lesson,
297
+ lesson_path,
298
+ package_id,
299
+ package_ids,
300
+ registry_path,
301
+ errors,
302
+ )
303
+ if isinstance(lesson, dict) and isinstance(lesson.get("id"), str):
304
+ lesson_id = lesson["id"]
305
+ if lesson_id in lesson_ids:
306
+ errors.append(f"duplicate lesson id {lesson_id!r}")
307
+ lesson_ids.add(lesson_id)
308
+ return errors
309
+
310
+
311
+ def validate_observed_identity(data: dict[str, Any]) -> list[str]:
312
+ errors: list[str] = []
313
+ if data.get("schema_version") != SCHEMA_VERSION:
314
+ errors.append(f"schema_version must be {SCHEMA_VERSION}")
315
+ declared = data.get("declared")
316
+ observed = data.get("observed")
317
+ if not isinstance(declared, dict):
318
+ errors.append("declared must be an object")
319
+ declared = {}
320
+ if not isinstance(observed, dict):
321
+ errors.append("observed must be an object")
322
+ observed = {}
323
+ for field in ("vendor", "model", "hardware_revision"):
324
+ nullable_nonempty(declared.get(field), f"declared.{field}", errors)
325
+ for field in ("target", "module"):
326
+ nullable_nonempty(observed.get(field), f"observed.{field}", errors)
327
+ nullable_positive_int(observed.get("flash_mb"), "observed.flash_mb", errors)
328
+ nullable_positive_int(observed.get("psram_mb"), "observed.psram_mb", errors)
329
+ for field, validator in (("components", validate_component), ("probes", validate_probe)):
330
+ values = observed.get(field, [])
331
+ if not isinstance(values, list):
332
+ errors.append(f"observed.{field} must be an array")
333
+ continue
334
+ for index, item in enumerate(values):
335
+ validator(item, f"observed.{field}[{index}]", errors)
336
+ if not any(normalized(declared.get(field)) for field in ("vendor", "model", "hardware_revision")) and not any(
337
+ observed.get(field) for field in ("target", "module", "components", "probes")
338
+ ):
339
+ errors.append("identity must contain declared board data or observed probes")
340
+ return errors
341
+
342
+
343
+ def pair_set(values: Any) -> set[tuple[str, str]]:
344
+ if not isinstance(values, list):
345
+ return set()
346
+ return {
347
+ (normalized(item.get("kind")), normalized(item.get("model")))
348
+ for item in values
349
+ if isinstance(item, dict) and normalized(item.get("kind")) and normalized(item.get("model"))
350
+ }
351
+
352
+
353
+ def probe_set(values: Any) -> set[tuple[str, str]]:
354
+ if not isinstance(values, list):
355
+ return set()
356
+ return {
357
+ (normalized(item.get("kind")), normalized(item.get("value")))
358
+ for item in values
359
+ if isinstance(item, dict) and normalized(item.get("kind")) and normalized(item.get("value"))
360
+ }
361
+
362
+
363
+ def match_board(board: dict[str, Any], query: dict[str, Any]) -> dict[str, Any] | None:
364
+ identity = board["identity"]
365
+ declared = query.get("declared", {})
366
+ observed = query.get("observed", {})
367
+ conflicts: list[str] = []
368
+ missing: list[str] = []
369
+ reasons: list[str] = []
370
+
371
+ package_names = {
372
+ normalized(identity.get("model")),
373
+ *(normalized(value) for value in identity.get("aliases", [])),
374
+ }
375
+ declared_model = normalized(declared.get("model"))
376
+ model_match = bool(declared_model and declared_model in package_names)
377
+ vendor_match = normalized(declared.get("vendor")) == normalized(identity.get("vendor")) if declared.get("vendor") else False
378
+ revision_match = normalized(declared.get("hardware_revision")) == normalized(identity.get("hardware_revision")) if declared.get("hardware_revision") else False
379
+
380
+ for field in ("target", "module", "flash_mb", "psram_mb"):
381
+ expected = identity.get(field)
382
+ actual = observed.get(field)
383
+ if expected is not None and actual is not None:
384
+ if normalized(expected) != normalized(actual):
385
+ conflicts.append(f"{field} differs: expected {expected}, observed {actual}")
386
+ else:
387
+ reasons.append(f"{field} matched")
388
+
389
+ expected_probes = probe_set(identity.get("probes", []))
390
+ actual_probes = probe_set(observed.get("probes", []))
391
+ observed_by_kind: dict[str, set[str]] = {}
392
+ for kind, value in actual_probes:
393
+ observed_by_kind.setdefault(kind, set()).add(value)
394
+ for probe in identity.get("probes", []):
395
+ if not isinstance(probe, dict) or not probe.get("required_for_exact"):
396
+ continue
397
+ key = (normalized(probe.get("kind")), normalized(probe.get("value")))
398
+ if key in actual_probes:
399
+ reasons.append(f"required probe {probe.get('kind')}={probe.get('value')} matched")
400
+ elif key[0] in observed_by_kind:
401
+ conflicts.append(
402
+ f"required probe {probe.get('kind')} differs from {probe.get('value')}"
403
+ )
404
+ else:
405
+ missing.append(f"required probe {probe.get('kind')}={probe.get('value')}")
406
+
407
+ expected_components = pair_set(identity.get("components", []))
408
+ actual_components = pair_set(observed.get("components", []))
409
+ shared_components = sorted(expected_components.intersection(actual_components))
410
+
411
+ if conflicts:
412
+ classification = "component" if shared_components else "none"
413
+ elif model_match and vendor_match and revision_match and not missing:
414
+ classification = "exact"
415
+ elif model_match or (model_match and vendor_match):
416
+ classification = "probable"
417
+ if not revision_match:
418
+ missing.append("exact hardware revision")
419
+ elif shared_components or expected_probes.intersection(actual_probes):
420
+ classification = "component"
421
+ else:
422
+ classification = "none"
423
+
424
+ if classification == "none":
425
+ return None
426
+ status = board["status"]
427
+ if classification == "exact" and status in {"adapter_verified", "hil_verified"}:
428
+ reuse = "registered_board"
429
+ elif classification == "exact":
430
+ reuse = "knowledge_only"
431
+ elif classification == "probable":
432
+ reuse = "candidate_only"
433
+ else:
434
+ reuse = "component_lessons_only"
435
+ rank = {"exact": 3, "probable": 2, "component": 1}[classification]
436
+ applicable_lessons = []
437
+ for lesson in board.get("lessons", []):
438
+ scope = lesson.get("scope")
439
+ if classification == "exact" or scope == "generic" or (
440
+ scope == "component" and shared_components
441
+ ):
442
+ applicable_lessons.append(lesson)
443
+ return {
444
+ "package_id": board["package_id"],
445
+ "status": status,
446
+ "classification": classification,
447
+ "reuse": reuse,
448
+ "rank": rank,
449
+ "reasons": reasons,
450
+ "missing": sorted(set(missing)),
451
+ "conflicts": conflicts,
452
+ "shared_components": [
453
+ {"kind": kind, "model": model} for kind, model in shared_components
454
+ ],
455
+ "applicable_lessons": applicable_lessons,
456
+ "artifacts": board.get("artifacts", {}) if reuse == "registered_board" else {},
457
+ }
458
+
459
+
460
+ def match_registry(registry: dict[str, Any], query: dict[str, Any]) -> dict[str, Any]:
461
+ matches = [
462
+ match
463
+ for board in registry.get("boards", [])
464
+ if (match := match_board(board, query)) is not None
465
+ ]
466
+ matches.sort(key=lambda item: (-item["rank"], item["package_id"]))
467
+ return {
468
+ "schema_version": SCHEMA_VERSION,
469
+ "result": matches[0]["classification"] if matches else "none",
470
+ "safe_registered_reuse": bool(
471
+ matches and matches[0]["reuse"] == "registered_board"
472
+ ),
473
+ "matches": matches,
474
+ }
475
+
476
+
477
+ def components_from_ir(data: dict[str, Any]) -> list[dict[str, str]]:
478
+ components: list[dict[str, str]] = []
479
+ camera = data.get("camera", {})
480
+ if camera.get("present") is True and isinstance(camera.get("sensor"), str):
481
+ components.append({"kind": "camera", "model": camera["sensor"]})
482
+ for section, kind in (("audio_input", "audio_input"), ("audio_output", "audio_output")):
483
+ value = data.get(section, {})
484
+ for codec in value.get("codecs", []) if isinstance(value, dict) else []:
485
+ if isinstance(codec, dict) and isinstance(codec.get("name"), str):
486
+ components.append({"kind": kind, "model": codec["name"]})
487
+ unique = {(normalized(item["kind"]), normalized(item["model"])): item for item in components}
488
+ return [unique[key] for key in sorted(unique)]
489
+
490
+
491
+ def candidate_from_ir(data: dict[str, Any]) -> dict[str, Any]:
492
+ board = data["board"]
493
+ soc = data["soc"]
494
+ toolchain = data["toolchain"]
495
+ tirtc = toolchain.get("tirtc", {})
496
+ resources = data.get("hardware_resources", {})
497
+ contracts = [
498
+ resources.get(field)
499
+ for field in (
500
+ "audio_semantic_contract",
501
+ "video_semantic_contract",
502
+ "runtime_semantic_contract",
503
+ )
504
+ if isinstance(resources.get(field), str) and resources.get(field)
505
+ ]
506
+ return {
507
+ "schema_version": SCHEMA_VERSION,
508
+ "promotion_status": "candidate",
509
+ "package_id": board["id"],
510
+ "identity": {
511
+ "vendor": board["vendor"],
512
+ "model": board["model"],
513
+ "aliases": [],
514
+ "hardware_revision": board["hardware_revision"],
515
+ "target": soc["target"],
516
+ "module": soc.get("module"),
517
+ "flash_mb": soc.get("flash_mb"),
518
+ "psram_mb": soc.get("psram_mb"),
519
+ "components": components_from_ir(data),
520
+ "probes": [],
521
+ },
522
+ "compatibility": {
523
+ "idf": toolchain["framework_version"],
524
+ "tirtc_sdk": tirtc.get("version", "unknown"),
525
+ },
526
+ "proposed_artifacts": {
527
+ "hardware_ir": "hardware-ir.json",
528
+ "adapter": None,
529
+ "config_overlay": "sdkconfig.defaults",
530
+ "contracts": contracts,
531
+ },
532
+ "lessons": [],
533
+ "promotion_requirements": [
534
+ "resolve exact PCB revision and every required runtime probe",
535
+ "copy project-relative Hardware IR, adapter, config overlay and contracts into the registry package",
536
+ "bind hardware/HIL lessons to an exact artifact SHA-256",
537
+ "add a focused regression test before promoting a generic invariant",
538
+ "review and commit the candidate; never mutate an installed Skill automatically",
539
+ ],
540
+ }
541
+
542
+
543
+ def default_registry() -> Path:
544
+ return Path(__file__).resolve().parent.parent / "knowledge" / "board-registry.json"
545
+
546
+
547
+ def load_valid_registry(path: Path) -> dict[str, Any]:
548
+ registry = load_json(path, "board registry")
549
+ errors = validate_registry(registry, path)
550
+ if errors:
551
+ raise ValueError("\n".join(f"error: {item}" for item in errors))
552
+ return registry
553
+
554
+
555
+ def command_validate(args: argparse.Namespace) -> int:
556
+ try:
557
+ registry = load_json(args.registry, "board registry")
558
+ except ValueError as exc:
559
+ print(str(exc), file=sys.stderr)
560
+ return 2
561
+ errors = validate_registry(registry, args.registry)
562
+ if errors:
563
+ for item in errors:
564
+ print(f"error: {item}", file=sys.stderr)
565
+ return 2
566
+ print(f"valid board registry: {args.registry} ({len(registry['boards'])} packages)")
567
+ return 0
568
+
569
+
570
+ def command_list(args: argparse.Namespace) -> int:
571
+ try:
572
+ registry = load_valid_registry(args.registry)
573
+ except ValueError as exc:
574
+ print(str(exc), file=sys.stderr)
575
+ return 2
576
+ if not registry["boards"]:
577
+ print("No curated board packages are registered.")
578
+ return 0
579
+ for board in registry["boards"]:
580
+ identity = board["identity"]
581
+ print(
582
+ f"{board['package_id']}\t{board['status']}\t"
583
+ f"{identity['vendor']} {identity['model']} {identity['hardware_revision']}"
584
+ )
585
+ return 0
586
+
587
+
588
+ def command_match(args: argparse.Namespace) -> int:
589
+ try:
590
+ registry = load_valid_registry(args.registry)
591
+ query = load_json(args.identity, "board identity")
592
+ except ValueError as exc:
593
+ print(str(exc), file=sys.stderr)
594
+ return 2
595
+ errors = validate_observed_identity(query)
596
+ if errors:
597
+ for item in errors:
598
+ print(f"error: {item}", file=sys.stderr)
599
+ return 2
600
+ print(json.dumps(match_registry(registry, query), indent=2, ensure_ascii=False))
601
+ return 0
602
+
603
+
604
+ def command_init_identity(args: argparse.Namespace) -> int:
605
+ output = args.output.resolve()
606
+ if output.exists():
607
+ print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
608
+ return 2
609
+ example = Path(__file__).resolve().parent.parent / "assets" / "board-identity.example.json"
610
+ output.parent.mkdir(parents=True, exist_ok=True)
611
+ shutil.copyfile(example, output)
612
+ print(f"created board identity: {output}")
613
+ return 0
614
+
615
+
616
+ def command_candidate(args: argparse.Namespace) -> int:
617
+ output = args.output.resolve()
618
+ if output.exists():
619
+ print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
620
+ return 2
621
+ try:
622
+ data = load_ir(args.hardware_ir)
623
+ except ValueError as exc:
624
+ print(str(exc), file=sys.stderr)
625
+ return 2
626
+ errors = validate_ir(data) + validate_source_locations(data, args.hardware_ir.parent)
627
+ if errors:
628
+ for item in errors:
629
+ print(f"error: {item}", file=sys.stderr)
630
+ return 2
631
+ output.parent.mkdir(parents=True, exist_ok=True)
632
+ output.write_text(
633
+ json.dumps(candidate_from_ir(data), indent=2, ensure_ascii=False) + "\n",
634
+ encoding="utf-8",
635
+ )
636
+ print(f"created board knowledge candidate: {output}")
637
+ return 0
638
+
639
+
640
+ def parse_args() -> argparse.Namespace:
641
+ parser = argparse.ArgumentParser(
642
+ description="Validate, match, and create candidates for ESP32 board knowledge."
643
+ )
644
+ subparsers = parser.add_subparsers(dest="command", required=True)
645
+
646
+ for name in ("validate", "list"):
647
+ child = subparsers.add_parser(name)
648
+ child.add_argument("--registry", type=Path, default=default_registry())
649
+
650
+ match_parser = subparsers.add_parser("match")
651
+ match_parser.add_argument("--registry", type=Path, default=default_registry())
652
+ match_parser.add_argument("--identity", type=Path, required=True)
653
+
654
+ identity_parser = subparsers.add_parser("init-identity")
655
+ identity_parser.add_argument("--output", type=Path, required=True)
656
+
657
+ candidate_parser = subparsers.add_parser("candidate")
658
+ candidate_parser.add_argument("--hardware-ir", type=Path, required=True)
659
+ candidate_parser.add_argument("--output", type=Path, required=True)
660
+ return parser.parse_args()
661
+
662
+
663
+ def main() -> int:
664
+ args = parse_args()
665
+ if args.command == "validate":
666
+ return command_validate(args)
667
+ if args.command == "list":
668
+ return command_list(args)
669
+ if args.command == "match":
670
+ return command_match(args)
671
+ if args.command == "init-identity":
672
+ return command_init_identity(args)
673
+ return command_candidate(args)
674
+
675
+
676
+ if __name__ == "__main__":
677
+ raise SystemExit(main())