tirtc-device-builder 0.5.0 → 0.6.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 (24) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +19 -9
  4. package/package.json +1 -1
  5. package/skills/tirtc-esp32-builder/SKILL.md +4 -4
  6. package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
  7. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +94 -0
  8. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +7 -4
  9. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +5 -0
  10. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +51 -0
  11. package/skills/tirtc-esp32-builder/assets/report-template.md +11 -0
  12. package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
  13. package/skills/tirtc-esp32-builder/references/capability-rules.md +1 -1
  14. package/skills/tirtc-esp32-builder/references/environment.md +8 -0
  15. package/skills/tirtc-esp32-builder/references/hardware-ir.md +6 -2
  16. package/skills/tirtc-esp32-builder/references/reporting.md +4 -2
  17. package/skills/tirtc-esp32-builder/references/video-contract.md +30 -0
  18. package/skills/tirtc-esp32-builder/references/workflow.md +1 -1
  19. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
  20. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +223 -4
  21. package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
  22. package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
  23. package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
  24. package/skills/tirtc-esp32-builder/scripts/video_contract.py +333 -0
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """Verify an ESP32 board audio contract against locked source and adapter files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ PAIR_PATTERN = re.compile(r"\{\s*(\d+)\s*,\s*(\d+)\s*,")
16
+ AUDIO_MODES = {"standard", "tdm", "dsp", "pcm"}
17
+ HANDOFF_POLICIES = {"none", "release_before_claim", "delete_recreate"}
18
+
19
+
20
+ def sha256_file(path: Path) -> str:
21
+ digest = hashlib.sha256()
22
+ with path.open("rb") as stream:
23
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
24
+ digest.update(chunk)
25
+ return digest.hexdigest()
26
+
27
+
28
+ def project_file(project: Path, value: Any, label: str) -> Path:
29
+ if not isinstance(value, str) or not value.strip():
30
+ raise ValueError(f"{label} must be a non-empty project-relative path")
31
+ relative = Path(value)
32
+ if relative.is_absolute():
33
+ raise ValueError(f"{label} must be project-relative, got {value!r}")
34
+ resolved = (project / relative).resolve()
35
+ if project != resolved and project not in resolved.parents:
36
+ raise ValueError(f"{label} escapes project root: {value!r}")
37
+ return resolved
38
+
39
+
40
+ def require_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]:
41
+ if not isinstance(value, dict):
42
+ errors.append(f"{label} must be an object")
43
+ return {}
44
+ return value
45
+
46
+
47
+ def require_positive_int(value: Any, label: str, errors: list[str]) -> int | None:
48
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
49
+ errors.append(f"{label} must be a positive integer")
50
+ return None
51
+ return value
52
+
53
+
54
+ def require_nonempty_string(value: Any, label: str, errors: list[str]) -> str | None:
55
+ if not isinstance(value, str) or not value.strip():
56
+ errors.append(f"{label} must be a non-empty string")
57
+ return None
58
+ return value
59
+
60
+
61
+ def check_clock(
62
+ project: Path,
63
+ contract: dict[str, Any],
64
+ errors: list[str],
65
+ inputs: dict[str, str],
66
+ ) -> None:
67
+ clock = require_mapping(contract.get("clock"), "clock", errors)
68
+ sample_rate = require_positive_int(
69
+ clock.get("sample_rate_hz"), "clock.sample_rate_hz", errors
70
+ )
71
+ ratio = require_positive_int(clock.get("mclk_ratio"), "clock.mclk_ratio", errors)
72
+ mclk = require_positive_int(clock.get("mclk_hz"), "clock.mclk_hz", errors)
73
+ if None not in (sample_rate, ratio, mclk) and sample_rate * ratio != mclk:
74
+ errors.append(
75
+ "clock tuple is inconsistent: "
76
+ f"{sample_rate}Hz x {ratio} != {mclk}Hz"
77
+ )
78
+
79
+ tables = clock.get("codec_tables")
80
+ if not isinstance(tables, list) or not tables:
81
+ errors.append("clock.codec_tables must list every clocked codec driver table")
82
+ return
83
+ if sample_rate is None or mclk is None:
84
+ return
85
+
86
+ for index, item in enumerate(tables):
87
+ label = f"clock.codec_tables[{index}]"
88
+ table = require_mapping(item, label, errors)
89
+ codec = require_nonempty_string(table.get("codec"), f"{label}.codec", errors)
90
+ try:
91
+ source = project_file(project, table.get("source"), f"{label}.source")
92
+ except ValueError as exc:
93
+ errors.append(str(exc))
94
+ continue
95
+ if not source.is_file():
96
+ errors.append(f"{label}.source does not exist: {source}")
97
+ continue
98
+ text = source.read_text(encoding="utf-8", errors="replace")
99
+ pairs = {(int(first), int(second)) for first, second in PAIR_PATTERN.findall(text)}
100
+ inputs[str(source.relative_to(project))] = sha256_file(source)
101
+ if not pairs:
102
+ errors.append(f"{label} contains no C initializer clock pairs")
103
+ elif (mclk, sample_rate) not in pairs:
104
+ errors.append(
105
+ f"{codec or label} rejects selected clock tuple: "
106
+ f"sample_rate={sample_rate}Hz mclk={mclk}Hz"
107
+ )
108
+
109
+
110
+ def check_endpoint(
111
+ value: Any,
112
+ label: str,
113
+ errors: list[str],
114
+ ) -> dict[str, Any]:
115
+ endpoint = require_mapping(value, label, errors)
116
+ controller = endpoint.get("controller")
117
+ if isinstance(controller, bool) or not isinstance(controller, int) or controller < 0:
118
+ errors.append(f"{label}.controller must be an integer >= 0")
119
+ mode = endpoint.get("mode")
120
+ if mode not in AUDIO_MODES:
121
+ errors.append(f"{label}.mode must be one of {', '.join(sorted(AUDIO_MODES))}")
122
+ role = endpoint.get("role")
123
+ if role not in {"master", "slave"}:
124
+ errors.append(f"{label}.role must be master or slave")
125
+ return endpoint
126
+
127
+
128
+ def check_topology(contract: dict[str, Any], errors: list[str]) -> None:
129
+ capture = check_endpoint(contract.get("capture"), "capture", errors)
130
+ playback = check_endpoint(contract.get("playback"), "playback", errors)
131
+
132
+ capture_mode = capture.get("mode")
133
+ tdm_enabled = capture.get("tdm_enabled")
134
+ if not isinstance(tdm_enabled, bool):
135
+ errors.append("capture.tdm_enabled must be true or false")
136
+ elif (capture_mode == "tdm") != tdm_enabled:
137
+ errors.append("capture.mode and capture.tdm_enabled disagree")
138
+
139
+ if capture_mode == "tdm":
140
+ slot_count = require_positive_int(
141
+ capture.get("slot_count"), "capture.slot_count", errors
142
+ )
143
+ slot_order = capture.get("slot_order")
144
+ slot_signals = capture.get("slot_signals")
145
+ selected_slot = capture.get("selected_slot")
146
+ selected_signal = capture.get("selected_signal")
147
+ if not isinstance(slot_order, list) or not slot_order:
148
+ errors.append("capture.slot_order must be a non-empty array in TDM mode")
149
+ if not isinstance(slot_signals, list) or not slot_signals:
150
+ errors.append("capture.slot_signals must be a non-empty array in TDM mode")
151
+ if slot_count is not None:
152
+ if isinstance(slot_order, list) and len(slot_order) != slot_count:
153
+ errors.append("capture.slot_order length must equal capture.slot_count")
154
+ if isinstance(slot_signals, list) and len(slot_signals) != slot_count:
155
+ errors.append("capture.slot_signals length must equal capture.slot_count")
156
+ if (
157
+ isinstance(selected_slot, bool)
158
+ or not isinstance(selected_slot, int)
159
+ or selected_slot < 0
160
+ or slot_count is None
161
+ or selected_slot >= slot_count
162
+ ):
163
+ errors.append("capture.selected_slot must select an available TDM slot")
164
+ elif isinstance(slot_signals, list) and selected_slot < len(slot_signals):
165
+ if selected_signal != slot_signals[selected_slot]:
166
+ errors.append(
167
+ "capture.selected_signal does not match capture.slot_signals at "
168
+ "capture.selected_slot"
169
+ )
170
+ require_nonempty_string(
171
+ capture.get("mapping_evidence"), "capture.mapping_evidence", errors
172
+ )
173
+ else:
174
+ require_nonempty_string(
175
+ capture.get("mapping_evidence"), "capture.mapping_evidence", errors
176
+ )
177
+
178
+ shared = require_mapping(contract.get("shared_clock"), "shared_clock", errors)
179
+ gpios = shared.get("gpios")
180
+ if not isinstance(gpios, list) or not gpios or any(
181
+ isinstance(gpio, bool) or not isinstance(gpio, int) or gpio < 0 for gpio in gpios
182
+ ):
183
+ errors.append("shared_clock.gpios must be a non-empty array of GPIO numbers")
184
+ simultaneous = shared.get("directions_simultaneous")
185
+ if not isinstance(simultaneous, bool):
186
+ errors.append("shared_clock.directions_simultaneous must be true or false")
187
+ handoff = shared.get("handoff")
188
+ if handoff not in HANDOFF_POLICIES:
189
+ errors.append(
190
+ "shared_clock.handoff must be one of "
191
+ + ", ".join(sorted(HANDOFF_POLICIES))
192
+ )
193
+ if simultaneous is False and handoff == "none":
194
+ errors.append("half-duplex shared clocks require an explicit handoff")
195
+ if simultaneous is True and capture.get("mode") != playback.get("mode"):
196
+ errors.append("simultaneous directions cannot use different I2S modes")
197
+ if (
198
+ capture.get("controller") == playback.get("controller")
199
+ and capture.get("mode") != playback.get("mode")
200
+ and handoff != "delete_recreate"
201
+ ):
202
+ errors.append(
203
+ "one I2S controller cannot retain different capture/playback modes; "
204
+ "use distinct controllers or delete_recreate handoff"
205
+ )
206
+
207
+
208
+ def check_assertions(
209
+ project: Path,
210
+ contract: dict[str, Any],
211
+ errors: list[str],
212
+ inputs: dict[str, str],
213
+ ) -> None:
214
+ assertions = contract.get("implementation_assertions")
215
+ if not isinstance(assertions, list) or not assertions:
216
+ errors.append("implementation_assertions must be a non-empty array")
217
+ return
218
+ for index, item in enumerate(assertions):
219
+ label = f"implementation_assertions[{index}]"
220
+ assertion = require_mapping(item, label, errors)
221
+ try:
222
+ source = project_file(project, assertion.get("file"), f"{label}.file")
223
+ except ValueError as exc:
224
+ errors.append(str(exc))
225
+ continue
226
+ if not source.is_file():
227
+ errors.append(f"{label}.file does not exist: {source}")
228
+ continue
229
+ text = source.read_text(encoding="utf-8", errors="replace")
230
+ compact = re.sub(r"\s+", "", text)
231
+ inputs[str(source.relative_to(project))] = sha256_file(source)
232
+ for field, haystack, present in (
233
+ ("contains", text, True),
234
+ ("contains_compact", compact, True),
235
+ ("absent", text, False),
236
+ ("absent_compact", compact, False),
237
+ ):
238
+ needles = assertion.get(field, [])
239
+ if not isinstance(needles, list) or any(
240
+ not isinstance(needle, str) or not needle for needle in needles
241
+ ):
242
+ errors.append(f"{label}.{field} must be an array of non-empty strings")
243
+ continue
244
+ for needle in needles:
245
+ found = needle in haystack
246
+ if found != present:
247
+ verb = "missing" if present else "contains forbidden"
248
+ errors.append(f"{label} {verb} {field} token: {needle}")
249
+
250
+
251
+ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
252
+ project = project_path.expanduser().resolve()
253
+ contract_file = contract_path.expanduser().resolve()
254
+ errors: list[str] = []
255
+ inputs: dict[str, str] = {}
256
+ if not project.is_dir():
257
+ return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
258
+ if not contract_file.is_file():
259
+ return {"ok": False, "errors": [f"audio contract does not exist: {contract_file}"]}
260
+ if project != contract_file and project not in contract_file.parents:
261
+ return {"ok": False, "errors": ["audio contract must be inside the project"]}
262
+ try:
263
+ contract = json.loads(contract_file.read_text(encoding="utf-8"))
264
+ except (OSError, json.JSONDecodeError) as exc:
265
+ return {"ok": False, "errors": [f"invalid audio contract: {exc}"]}
266
+ if not isinstance(contract, dict):
267
+ return {"ok": False, "errors": ["audio contract root must be an object"]}
268
+ if contract.get("schema_version") != 1:
269
+ errors.append("schema_version must be 1")
270
+ evidence = contract.get("evidence")
271
+ if not isinstance(evidence, list) or len(evidence) < 2 or any(
272
+ not isinstance(item, str) or not item for item in evidence
273
+ ):
274
+ errors.append("evidence must contain at least two non-empty source IDs")
275
+ inputs[str(contract_file.relative_to(project))] = sha256_file(contract_file)
276
+ check_clock(project, contract, errors, inputs)
277
+ check_topology(contract, errors)
278
+ check_assertions(project, contract, errors, inputs)
279
+ clock = contract.get("clock", {})
280
+ return {
281
+ "ok": not errors,
282
+ "summary": (
283
+ f"sample_rate={clock.get('sample_rate_hz')}Hz "
284
+ f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')}"
285
+ ),
286
+ "inputs": inputs,
287
+ "errors": errors,
288
+ }
289
+
290
+
291
+ def parse_args() -> argparse.Namespace:
292
+ parser = argparse.ArgumentParser(
293
+ description="Verify codec clocks, I2S topology, channel mapping, and adapter assertions."
294
+ )
295
+ parser.add_argument("contract", type=Path)
296
+ parser.add_argument("--project", type=Path, required=True)
297
+ parser.add_argument("--json", action="store_true")
298
+ parser.add_argument("--evidence-out", type=Path)
299
+ return parser.parse_args()
300
+
301
+
302
+ def main() -> int:
303
+ args = parse_args()
304
+ result = verify_contract(args.contract, args.project)
305
+ if args.evidence_out is not None:
306
+ output = args.evidence_out.expanduser().resolve()
307
+ output.parent.mkdir(parents=True, exist_ok=True)
308
+ output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
309
+ if args.json:
310
+ print(json.dumps(result, ensure_ascii=False, indent=2))
311
+ elif result["ok"]:
312
+ print(f"PASS: audio semantic contract: {result['summary']}")
313
+ else:
314
+ for error in result["errors"]:
315
+ print(f"FAIL: {error}", file=sys.stderr)
316
+ return 0 if result["ok"] else 3
317
+
318
+
319
+ if __name__ == "__main__":
320
+ raise SystemExit(main())
@@ -11,6 +11,9 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from audio_contract import verify_contract as verify_audio_contract
15
+ from video_contract import verify_contract as verify_video_contract
16
+
14
17
 
15
18
  FEATURES = {
16
19
  "h5_live_audio",
@@ -18,6 +21,8 @@ FEATURES = {
18
21
  "h5_talkback",
19
22
  "ai_talk",
20
23
  }
24
+ AUDIO_FEATURES = {"h5_live_audio", "h5_talkback", "ai_talk"}
25
+ VIDEO_FEATURES = {"h5_live_video"}
21
26
  VERIFICATION_LEVELS = {
22
27
  "extracted": 1,
23
28
  "corroborated": 2,
@@ -220,6 +225,26 @@ def validate_hardware_resources(
220
225
  validate_verification(
221
226
  i2s.get("verification"), "hardware_resources.i2s.verification", errors
222
227
  )
228
+ audio_contract = resources.get("audio_semantic_contract")
229
+ nullable_string(
230
+ audio_contract,
231
+ "hardware_resources.audio_semantic_contract",
232
+ errors,
233
+ )
234
+ if isinstance(audio_contract, str) and Path(audio_contract).is_absolute():
235
+ errors.append(
236
+ "hardware_resources.audio_semantic_contract must be project-relative"
237
+ )
238
+ video_contract = resources.get("video_semantic_contract")
239
+ nullable_string(
240
+ video_contract,
241
+ "hardware_resources.video_semantic_contract",
242
+ errors,
243
+ )
244
+ if isinstance(video_contract, str) and Path(video_contract).is_absolute():
245
+ errors.append(
246
+ "hardware_resources.video_semantic_contract must be project-relative"
247
+ )
223
248
 
224
249
  mapping_section = mapping(
225
250
  resources.get("audio_channel_mapping"),
@@ -396,6 +421,27 @@ def validate_runtime_evidence(
396
421
  validate_source_refs(record, prefix, source_ids, errors)
397
422
 
398
423
 
424
+ def validate_build_evidence(data: dict[str, Any], errors: list[str]) -> None:
425
+ if "build_evidence" not in data:
426
+ return
427
+ evidence = mapping(data.get("build_evidence"), "build_evidence", errors)
428
+ artifacts = evidence.get("artifacts")
429
+ if not isinstance(artifacts, list):
430
+ errors.append("build_evidence.artifacts must be an array")
431
+ return
432
+ for index, item in enumerate(artifacts):
433
+ prefix = f"build_evidence.artifacts[{index}]"
434
+ record = mapping(item, prefix, errors)
435
+ path_value = record.get("path")
436
+ nonempty_string(path_value, f"{prefix}.path", errors)
437
+ if isinstance(path_value, str) and Path(path_value).is_absolute():
438
+ errors.append(f"{prefix}.path must be project-relative")
439
+ positive_int(record.get("size_bytes"), f"{prefix}.size_bytes", errors)
440
+ sha = record.get("sha256")
441
+ if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
442
+ errors.append(f"{prefix}.sha256 must be a 64-character SHA-256")
443
+
444
+
399
445
  def validate_ir(data: dict[str, Any]) -> list[str]:
400
446
  errors: list[str] = []
401
447
  schema_version = data.get("schema_version")
@@ -483,6 +529,7 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
483
529
  if schema_version == 2:
484
530
  validate_hardware_resources(data, source_ids, errors)
485
531
  validate_onboarding(data, source_ids, errors)
532
+ validate_build_evidence(data, errors)
486
533
  validate_runtime_evidence(data, source_ids, errors)
487
534
  return errors
488
535
 
@@ -818,6 +865,24 @@ def combine_requirements(
818
865
  return {"status": status, "reasons": reasons}
819
866
 
820
867
 
868
+ def constrain_project_gate(
869
+ project_gate: dict[str, Any], feature_assessments: dict[str, dict[str, Any]]
870
+ ) -> None:
871
+ """Prevent a project-level pass while a requested feature is unresolved."""
872
+ feature_states = {item["status"] for item in feature_assessments.values()}
873
+ if "BLOCKED" in feature_states:
874
+ project_gate["status"] = "BLOCKED"
875
+ project_gate["reasons"].append("at least one requested feature is blocked")
876
+ elif (
877
+ "NEEDS_CONFIRMATION" in feature_states
878
+ and project_gate["status"] != "BLOCKED"
879
+ ):
880
+ project_gate["status"] = "NEEDS_CONFIRMATION"
881
+ project_gate["reasons"].append(
882
+ "at least one requested feature still needs confirmation"
883
+ )
884
+
885
+
821
886
  def project_requirements(data: dict[str, Any]) -> list[Requirement]:
822
887
  requirements: list[Requirement] = []
823
888
  revision = data["board"]["hardware_revision"].strip().lower()
@@ -887,11 +952,40 @@ def matching_runtime_evidence(
887
952
  return None
888
953
 
889
954
 
890
- def artifact_requirement(artifact_sha256: str | None) -> Requirement:
955
+ def matching_build_artifact(
956
+ data: dict[str, Any], artifact_sha256: str | None
957
+ ) -> dict[str, Any] | None:
958
+ if artifact_sha256 is None:
959
+ return None
960
+ normalized = artifact_sha256.lower()
961
+ build_evidence = data.get("build_evidence", {})
962
+ if not isinstance(build_evidence, dict):
963
+ return None
964
+ artifacts = build_evidence.get("artifacts", [])
965
+ if not isinstance(artifacts, list):
966
+ return None
967
+ for record in artifacts:
968
+ if (
969
+ isinstance(record, dict)
970
+ and str(record.get("sha256", "")).lower() == normalized
971
+ ):
972
+ return record
973
+ return None
974
+
975
+
976
+ def artifact_requirement(
977
+ data: dict[str, Any], artifact_sha256: str | None
978
+ ) -> Requirement:
891
979
  if artifact_sha256 is None:
892
980
  return "NEEDS_CONFIRMATION", "exact build artifact SHA-256 is missing", 0
893
981
  if not SHA256_RE.fullmatch(artifact_sha256):
894
982
  return "BLOCKED", "build artifact SHA-256 is invalid", 0
983
+ if matching_build_artifact(data, artifact_sha256) is None:
984
+ return (
985
+ "BLOCKED",
986
+ f"artifact {artifact_sha256} is not present in build_evidence.artifacts",
987
+ 0,
988
+ )
895
989
  return (
896
990
  "SATISFIED",
897
991
  f"build evidence is bound to artifact {artifact_sha256}",
@@ -903,6 +997,8 @@ def assess_ir(
903
997
  data: dict[str, Any],
904
998
  artifact_sha256: str | None = None,
905
999
  phase: str | None = None,
1000
+ audio_gate: Requirement | None = None,
1001
+ video_gate: Requirement | None = None,
906
1002
  ) -> dict[str, Any]:
907
1003
  selected_phase = phase or ("hil" if artifact_sha256 else "intake")
908
1004
  if selected_phase not in ASSESSMENT_PHASES:
@@ -947,7 +1043,7 @@ def assess_ir(
947
1043
  resources = {}
948
1044
 
949
1045
  if selected_phase in {"build", "hil"}:
950
- project.append(artifact_requirement(artifact_sha256))
1046
+ project.append(artifact_requirement(data, artifact_sha256))
951
1047
 
952
1048
  for feature in requested:
953
1049
  if schema_version == 1:
@@ -1010,6 +1106,24 @@ def assess_ir(
1010
1106
  audio_mapping_requirement(resources, minimum_verification),
1011
1107
  memory_requirement(resources, minimum_verification),
1012
1108
  ]
1109
+ if feature in AUDIO_FEATURES and selected_phase in {"build", "hil"}:
1110
+ requirements.append(
1111
+ audio_gate
1112
+ or (
1113
+ "NEEDS_CONFIRMATION",
1114
+ "audio semantic gate was not executed for this project",
1115
+ 0,
1116
+ )
1117
+ )
1118
+ if feature in VIDEO_FEATURES and selected_phase in {"build", "hil"}:
1119
+ requirements.append(
1120
+ video_gate
1121
+ or (
1122
+ "NEEDS_CONFIRMATION",
1123
+ "video semantic gate was not executed for this project",
1124
+ 0,
1125
+ )
1126
+ )
1013
1127
  result[feature] = combine_requirements(
1014
1128
  requirements, success_status=success_status
1015
1129
  )
@@ -1019,6 +1133,7 @@ def assess_ir(
1019
1133
  success_status=success_status,
1020
1134
  legacy_hil_from_levels=(schema_version == 1 and selected_phase == "intake"),
1021
1135
  )
1136
+ build_artifact = matching_build_artifact(data, artifact_sha256)
1022
1137
  evidence = matching_runtime_evidence(data, artifact_sha256)
1023
1138
  if schema_version == 2 and selected_phase == "hil":
1024
1139
  evidence_features = set(evidence.get("features", [])) if evidence else set()
@@ -1046,6 +1161,8 @@ def assess_ir(
1046
1161
  f"all requested features have matching artifact evidence for {artifact_sha256}"
1047
1162
  )
1048
1163
 
1164
+ constrain_project_gate(project_gate, result)
1165
+
1049
1166
  assessment: dict[str, Any] = {
1050
1167
  "schema_version": schema_version,
1051
1168
  "phase": selected_phase,
@@ -1059,7 +1176,8 @@ def assess_ir(
1059
1176
  assessment["selected_wifi_method"] = selected_wifi
1060
1177
  assessment["selected_binding_method"] = selected_binding
1061
1178
  assessment["artifact_sha256"] = artifact_sha256
1062
- assessment["artifact_evidence_matched"] = evidence is not None
1179
+ assessment["build_artifact_evidence_matched"] = build_artifact is not None
1180
+ assessment["runtime_artifact_evidence_matched"] = evidence is not None
1063
1181
  return assessment
1064
1182
 
1065
1183
 
@@ -1110,15 +1228,111 @@ def command_assess(args: argparse.Namespace) -> int:
1110
1228
  ):
1111
1229
  print("error: --artifact-sha256 must be a 64-character SHA-256", file=sys.stderr)
1112
1230
  return 2
1231
+ selected_phase = args.phase or (
1232
+ "hil" if args.artifact_sha256 is not None else "intake"
1233
+ )
1234
+ project = (args.project or args.path.parent).expanduser().resolve()
1235
+ audio_gate: Requirement | None = None
1236
+ audio_gate_result: dict[str, Any] | None = None
1237
+ if (
1238
+ data.get("schema_version") == 2
1239
+ and selected_phase in {"build", "hil"}
1240
+ and AUDIO_FEATURES.intersection(data["features"]["requested"])
1241
+ ):
1242
+ relative_contract = data["hardware_resources"].get(
1243
+ "audio_semantic_contract"
1244
+ )
1245
+ if not isinstance(relative_contract, str) or not relative_contract:
1246
+ audio_gate = (
1247
+ "NEEDS_CONFIRMATION",
1248
+ "hardware_resources.audio_semantic_contract is missing",
1249
+ 0,
1250
+ )
1251
+ audio_gate_result = {
1252
+ "ok": False,
1253
+ "errors": ["audio semantic contract is missing"],
1254
+ }
1255
+ else:
1256
+ contract = (project / relative_contract).resolve()
1257
+ if project != contract and project not in contract.parents:
1258
+ audio_gate_result = {
1259
+ "ok": False,
1260
+ "errors": ["audio semantic contract escapes project root"],
1261
+ }
1262
+ else:
1263
+ audio_gate_result = verify_audio_contract(contract, project)
1264
+ if audio_gate_result["ok"]:
1265
+ audio_gate = (
1266
+ "SATISFIED",
1267
+ "audio semantic gate passed: "
1268
+ + str(audio_gate_result.get("summary", "verified")),
1269
+ VERIFICATION_LEVELS["build_verified"],
1270
+ )
1271
+ else:
1272
+ audio_gate = (
1273
+ "BLOCKED",
1274
+ "audio semantic gate failed: "
1275
+ + "; ".join(audio_gate_result.get("errors", [])),
1276
+ 0,
1277
+ )
1278
+ video_gate: Requirement | None = None
1279
+ video_gate_result: dict[str, Any] | None = None
1280
+ if (
1281
+ data.get("schema_version") == 2
1282
+ and selected_phase in {"build", "hil"}
1283
+ and VIDEO_FEATURES.intersection(data["features"]["requested"])
1284
+ ):
1285
+ relative_contract = data["hardware_resources"].get(
1286
+ "video_semantic_contract"
1287
+ )
1288
+ if not isinstance(relative_contract, str) or not relative_contract:
1289
+ video_gate = (
1290
+ "NEEDS_CONFIRMATION",
1291
+ "hardware_resources.video_semantic_contract is missing",
1292
+ 0,
1293
+ )
1294
+ video_gate_result = {
1295
+ "ok": False,
1296
+ "errors": ["video semantic contract is missing"],
1297
+ }
1298
+ else:
1299
+ contract = (project / relative_contract).resolve()
1300
+ if project != contract and project not in contract.parents:
1301
+ video_gate_result = {
1302
+ "ok": False,
1303
+ "errors": ["video semantic contract escapes project root"],
1304
+ }
1305
+ else:
1306
+ video_gate_result = verify_video_contract(contract, project)
1307
+ if video_gate_result["ok"]:
1308
+ video_gate = (
1309
+ "SATISFIED",
1310
+ "video semantic gate passed: "
1311
+ + str(video_gate_result.get("summary", "verified")),
1312
+ VERIFICATION_LEVELS["build_verified"],
1313
+ )
1314
+ else:
1315
+ video_gate = (
1316
+ "BLOCKED",
1317
+ "video semantic gate failed: "
1318
+ + "; ".join(video_gate_result.get("errors", [])),
1319
+ 0,
1320
+ )
1113
1321
  try:
1114
1322
  assessment = assess_ir(
1115
1323
  data,
1116
1324
  artifact_sha256=args.artifact_sha256,
1117
- phase=args.phase,
1325
+ phase=selected_phase,
1326
+ audio_gate=audio_gate,
1327
+ video_gate=video_gate,
1118
1328
  )
1119
1329
  except ValueError as exc:
1120
1330
  print(f"error: {exc}", file=sys.stderr)
1121
1331
  return 2
1332
+ if audio_gate_result is not None:
1333
+ assessment["audio_semantic_gate"] = audio_gate_result
1334
+ if video_gate_result is not None:
1335
+ assessment["video_semantic_gate"] = video_gate_result
1122
1336
  print(json.dumps(assessment, ensure_ascii=False, indent=2))
1123
1337
  if args.strict:
1124
1338
  statuses = {item["status"] for item in assessment["features"].values()}
@@ -1161,6 +1375,11 @@ def parse_args() -> argparse.Namespace:
1161
1375
  "--artifact-sha256",
1162
1376
  help="bind build or HIL assessment to this exact firmware artifact",
1163
1377
  )
1378
+ assess_parser.add_argument(
1379
+ "--project",
1380
+ type=Path,
1381
+ help="generated project root; defaults to the Hardware IR directory",
1382
+ )
1164
1383
  assess_parser.set_defaults(handler=command_assess)
1165
1384
  return parser.parse_args()
1166
1385