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
@@ -22,8 +22,18 @@ FEATURES = {
22
22
  "h5_live_video",
23
23
  "h5_talkback",
24
24
  "ai_talk",
25
+ "device_call",
26
+ "wechat_voip",
25
27
  }
26
- AUDIO_FEATURES = {"h5_live_audio", "h5_talkback", "ai_talk"}
28
+ AUDIO_FEATURES = {
29
+ "h5_live_audio",
30
+ "h5_talkback",
31
+ "ai_talk",
32
+ "device_call",
33
+ "wechat_voip",
34
+ }
35
+ AEC_REQUIRED_FEATURES = {"ai_talk", "device_call", "wechat_voip"}
36
+ BUSINESS_FEATURES = {"device_call", "wechat_voip"}
27
37
  VIDEO_FEATURES = {"h5_live_video"}
28
38
  VERIFICATION_LEVELS = {
29
39
  "extracted": 1,
@@ -63,6 +73,8 @@ FEATURE_HIL_LEVEL = {
63
73
  "h5_live_video": "L5",
64
74
  "h5_talkback": "L5",
65
75
  "ai_talk": "L6",
76
+ "device_call": "L6",
77
+ "wechat_voip": "L6",
66
78
  }
67
79
  SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
68
80
  SOURCE_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
@@ -345,6 +357,28 @@ def validate_hardware_resources(
345
357
  errors,
346
358
  )
347
359
 
360
+ duplex = mapping(
361
+ resources.get("duplex_audio", {}),
362
+ "hardware_resources.duplex_audio",
363
+ errors,
364
+ )
365
+ for field in (
366
+ "simultaneous_capture_playback",
367
+ "playback_reference_available",
368
+ "aec_implementation_available",
369
+ ):
370
+ nullable_bool(
371
+ duplex.get(field),
372
+ f"hardware_resources.duplex_audio.{field}",
373
+ errors,
374
+ )
375
+ if "verification" in duplex:
376
+ validate_verification(
377
+ duplex.get("verification"),
378
+ "hardware_resources.duplex_audio.verification",
379
+ errors,
380
+ )
381
+
348
382
  realtime = mapping(
349
383
  resources.get("camera_realtime"),
350
384
  "hardware_resources.camera_realtime",
@@ -826,6 +860,27 @@ def audio_mapping_requirement(
826
860
  )
827
861
 
828
862
 
863
+ def aec_capability_requirement(
864
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
865
+ ) -> Requirement:
866
+ duplex = resources.get("duplex_audio", {})
867
+ for field, label in (
868
+ ("simultaneous_capture_playback", "simultaneous capture and playback"),
869
+ ("playback_reference_available", "physical playback-reference capture path"),
870
+ ("aec_implementation_available", "AEC processor implementation"),
871
+ ):
872
+ requirement = verified_bool_requirement(
873
+ duplex, field, label, minimum_verification
874
+ )
875
+ if requirement[0] != "SATISFIED":
876
+ return requirement
877
+ return (
878
+ "SATISFIED",
879
+ "full-duplex audio, playback reference, and AEC implementation are resolved",
880
+ VERIFICATION_LEVELS.get(duplex.get("verification"), 0),
881
+ )
882
+
883
+
829
884
  def camera_realtime_requirement(
830
885
  resources: dict[str, Any], minimum_verification: str = "corroborated"
831
886
  ) -> Requirement:
@@ -979,20 +1034,22 @@ def project_requirements(data: dict[str, Any]) -> list[Requirement]:
979
1034
  )
980
1035
 
981
1036
  target = data["soc"]["target"].strip().lower()
982
- if target != "esp32s3":
1037
+ if target not in {"esp32s3", "esp32p4"}:
983
1038
  requirements.append(
984
1039
  (
985
1040
  "BLOCKED",
986
- f"current starter generator supports esp32s3, not {target}",
1041
+ f"TiRTC ESP32 workflow supports esp32s3 or esp32p4, not {target}",
987
1042
  0,
988
1043
  )
989
1044
  )
990
1045
  else:
991
- requirements.append(("SATISFIED", "ESP32-S3 starter is available", 2))
1046
+ requirements.append(
1047
+ ("SATISFIED", f"{target} is a supported TiRTC target", 2)
1048
+ )
992
1049
 
993
1050
  platform = data["toolchain"]["tirtc"]["platform"].strip().lower()
994
- expected_platform = "espressif-esp32s3"
995
- if target == "esp32s3" and platform != expected_platform:
1051
+ expected_platform = f"espressif-{target}"
1052
+ if target in {"esp32s3", "esp32p4"} and platform != expected_platform:
996
1053
  requirements.append(
997
1054
  (
998
1055
  "BLOCKED",
@@ -1113,6 +1170,8 @@ def assess_ir(
1113
1170
  artifact_sha256: str | None = None,
1114
1171
  phase: str | None = None,
1115
1172
  audio_gate: Requirement | None = None,
1173
+ aec_gate: Requirement | None = None,
1174
+ business_gate: Requirement | None = None,
1116
1175
  video_gate: Requirement | None = None,
1117
1176
  runtime_gate: Requirement | None = None,
1118
1177
  artifact_gate: Requirement | None = None,
@@ -1189,6 +1248,14 @@ def assess_ir(
1189
1248
  audio_output, "audio_output", minimum_verification
1190
1249
  ),
1191
1250
  ]
1251
+ if feature in AEC_REQUIRED_FEATURES:
1252
+ requirements.append(
1253
+ (
1254
+ "NEEDS_CONFIRMATION",
1255
+ "AEC-required sessions need Hardware IR v2 duplex_audio evidence",
1256
+ 0,
1257
+ )
1258
+ )
1192
1259
  result[feature] = combine_requirements(
1193
1260
  requirements,
1194
1261
  success_status=success_status,
@@ -1223,6 +1290,10 @@ def assess_ir(
1223
1290
  audio_mapping_requirement(resources, minimum_verification),
1224
1291
  memory_requirement(resources, minimum_verification),
1225
1292
  ]
1293
+ if feature in AEC_REQUIRED_FEATURES:
1294
+ requirements.append(
1295
+ aec_capability_requirement(resources, minimum_verification)
1296
+ )
1226
1297
  if feature in AUDIO_FEATURES and selected_phase in {"build", "hil"}:
1227
1298
  requirements.append(
1228
1299
  audio_gate
@@ -1232,6 +1303,15 @@ def assess_ir(
1232
1303
  0,
1233
1304
  )
1234
1305
  )
1306
+ if feature in AEC_REQUIRED_FEATURES and selected_phase in {"build", "hil"}:
1307
+ requirements.append(
1308
+ aec_gate
1309
+ or (
1310
+ "BLOCKED",
1311
+ "AEC semantic gate did not prove simultaneous full-duplex audio and enabled echo cancellation",
1312
+ 0,
1313
+ )
1314
+ )
1235
1315
  if feature in VIDEO_FEATURES and selected_phase in {"build", "hil"}:
1236
1316
  requirements.append(
1237
1317
  video_gate
@@ -1250,6 +1330,15 @@ def assess_ir(
1250
1330
  0,
1251
1331
  )
1252
1332
  )
1333
+ if feature in BUSINESS_FEATURES and selected_phase in {"build", "hil"}:
1334
+ requirements.append(
1335
+ business_gate
1336
+ or (
1337
+ "BLOCKED",
1338
+ "business runtime gate did not prove the requested call/VoIP protocol and session arbiter",
1339
+ 0,
1340
+ )
1341
+ )
1253
1342
  result[feature] = combine_requirements(
1254
1343
  requirements, success_status=success_status
1255
1344
  )
@@ -1359,6 +1448,7 @@ def command_assess(args: argparse.Namespace) -> int:
1359
1448
  )
1360
1449
  project = (args.project or args.path.parent).expanduser().resolve()
1361
1450
  audio_gate: Requirement | None = None
1451
+ aec_gate: Requirement | None = None
1362
1452
  audio_gate_result: dict[str, Any] | None = None
1363
1453
  if (
1364
1454
  data.get("schema_version") == 2
@@ -1394,6 +1484,24 @@ def command_assess(args: argparse.Namespace) -> int:
1394
1484
  + str(audio_gate_result.get("summary", "verified")),
1395
1485
  VERIFICATION_LEVELS["build_verified"],
1396
1486
  )
1487
+ if AEC_REQUIRED_FEATURES.intersection(
1488
+ data["features"]["requested"]
1489
+ ):
1490
+ if (
1491
+ audio_gate_result.get("simultaneous_capture_playback") is True
1492
+ and audio_gate_result.get("echo_cancellation_enabled") is True
1493
+ ):
1494
+ aec_gate = (
1495
+ "SATISFIED",
1496
+ "AEC gate proved simultaneous capture/playback with echo cancellation enabled",
1497
+ VERIFICATION_LEVELS["build_verified"],
1498
+ )
1499
+ else:
1500
+ aec_gate = (
1501
+ "BLOCKED",
1502
+ "AI/call/VoIP requires simultaneous capture/playback and echo_cancellation.enabled=true",
1503
+ 0,
1504
+ )
1397
1505
  else:
1398
1506
  audio_gate = (
1399
1507
  "BLOCKED",
@@ -1445,6 +1553,7 @@ def command_assess(args: argparse.Namespace) -> int:
1445
1553
  0,
1446
1554
  )
1447
1555
  runtime_gate: Requirement | None = None
1556
+ business_gate: Requirement | None = None
1448
1557
  runtime_gate_result: dict[str, Any] | None = None
1449
1558
  if data.get("schema_version") == 2 and selected_phase in {"build", "hil"}:
1450
1559
  relative_contract = data["hardware_resources"].get(
@@ -1469,13 +1578,41 @@ def command_assess(args: argparse.Namespace) -> int:
1469
1578
  }
1470
1579
  else:
1471
1580
  runtime_gate_result = verify_runtime_contract(contract, project)
1472
- if runtime_gate_result["ok"]:
1581
+ if runtime_gate_result.get("base_ok", runtime_gate_result["ok"]):
1582
+ requested_business = (
1583
+ set(data["features"]["requested"]) & BUSINESS_FEATURES
1584
+ )
1585
+ verified_business = set(
1586
+ runtime_gate_result.get("business_features", [])
1587
+ )
1588
+ missing_business = requested_business - verified_business
1473
1589
  runtime_gate = (
1474
1590
  "SATISFIED",
1475
- "TiRTC runtime semantic gate passed: "
1591
+ "TiRTC base runtime semantic gate passed: "
1476
1592
  + str(runtime_gate_result.get("summary", "verified")),
1477
1593
  VERIFICATION_LEVELS["build_verified"],
1478
1594
  )
1595
+ if requested_business and (
1596
+ runtime_gate_result.get("business_ok") is not True
1597
+ or missing_business
1598
+ ):
1599
+ details = list(runtime_gate_result.get("business_errors", []))
1600
+ if missing_business:
1601
+ details.append(
1602
+ "missing features: " + ", ".join(sorted(missing_business))
1603
+ )
1604
+ business_gate = (
1605
+ "BLOCKED",
1606
+ "TiRTC business runtime gate failed: " + "; ".join(details),
1607
+ 0,
1608
+ )
1609
+ elif requested_business:
1610
+ business_gate = (
1611
+ "SATISFIED",
1612
+ "TiRTC business runtime gate verified: "
1613
+ + ", ".join(sorted(requested_business)),
1614
+ VERIFICATION_LEVELS["build_verified"],
1615
+ )
1479
1616
  else:
1480
1617
  runtime_gate = (
1481
1618
  "BLOCKED",
@@ -1494,6 +1631,8 @@ def command_assess(args: argparse.Namespace) -> int:
1494
1631
  artifact_sha256=args.artifact_sha256,
1495
1632
  phase=selected_phase,
1496
1633
  audio_gate=audio_gate,
1634
+ aec_gate=aec_gate,
1635
+ business_gate=business_gate,
1497
1636
  video_gate=video_gate,
1498
1637
  runtime_gate=runtime_gate,
1499
1638
  artifact_gate=artifact_gate,
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import argparse
7
7
  import json
8
8
  import re
9
+ import subprocess
9
10
  import sys
10
11
  from pathlib import Path
11
12
  from typing import Any
@@ -13,8 +14,30 @@ from typing import Any
13
14
 
14
15
  ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
15
16
  BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
16
- BUILD_INPUT_SUFFIXES = {".c", ".h", ".cmake", ".py", ".sh"}
17
+ BUILD_INPUT_SUFFIXES = {
18
+ ".c",
19
+ ".cmake",
20
+ ".csv",
21
+ ".defaults",
22
+ ".env",
23
+ ".h",
24
+ ".json",
25
+ ".lock",
26
+ ".py",
27
+ ".sh",
28
+ ".txt",
29
+ ".yaml",
30
+ ".yml",
31
+ }
17
32
  SKIP_PARTS = {"build", "managed_components", ".git"}
33
+ REQUIRED_PROJECT_INPUTS = {
34
+ "dependencies.lock",
35
+ "hardware-ir.json",
36
+ "sdkconfig.defaults",
37
+ }
38
+ PARTITION_FILE_RE = re.compile(
39
+ r'^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="([^"]+)"$', re.MULTILINE
40
+ )
18
41
 
19
42
 
20
43
  def source_files(project: Path):
@@ -26,13 +49,156 @@ def source_files(project: Path):
26
49
  yield path
27
50
 
28
51
 
52
+ def project_file(
53
+ project: Path, value: Any, label: str, errors: list[str]
54
+ ) -> Path | None:
55
+ if not isinstance(value, str) or not value.strip():
56
+ errors.append(f"{label} must be a non-empty project-relative path")
57
+ return None
58
+ relative = Path(value)
59
+ if relative.is_absolute():
60
+ errors.append(f"{label} must be project-relative, got {value!r}")
61
+ return None
62
+ resolved = (project / relative).resolve()
63
+ if project != resolved and project not in resolved.parents:
64
+ errors.append(f"{label} escapes project root: {value!r}")
65
+ return None
66
+ return resolved
67
+
68
+
69
+ def load_hardware_ir(project: Path, errors: list[str]) -> dict[str, Any] | None:
70
+ path = project / "hardware-ir.json"
71
+ if not path.is_file():
72
+ return None
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, json.JSONDecodeError) as exc:
76
+ errors.append(f"hardware-ir.json is invalid: {exc}")
77
+ return None
78
+ if not isinstance(data, dict):
79
+ errors.append("hardware-ir.json root must be an object")
80
+ return None
81
+ return data
82
+
83
+
84
+ def required_project_files(
85
+ project: Path, export: bool, errors: list[str]
86
+ ) -> set[Path]:
87
+ required = {(project / relative).resolve() for relative in REQUIRED_PROJECT_INPUTS}
88
+ ir = load_hardware_ir(project, errors)
89
+ if ir is not None:
90
+ schema_version = ir.get("schema_version")
91
+ requested = ir.get("features", {}).get("requested", [])
92
+ if schema_version == 2:
93
+ resources = ir.get("hardware_resources")
94
+ if not isinstance(resources, dict):
95
+ errors.append("hardware-ir.json hardware_resources must be an object")
96
+ else:
97
+ contract_fields = ["runtime_semantic_contract"]
98
+ if {"h5_live_audio", "h5_talkback", "ai_talk"}.intersection(
99
+ requested
100
+ ):
101
+ contract_fields.append("audio_semantic_contract")
102
+ if "h5_live_video" in requested:
103
+ contract_fields.append("video_semantic_contract")
104
+ for field in contract_fields:
105
+ path = project_file(
106
+ project,
107
+ resources.get(field),
108
+ f"hardware_resources.{field}",
109
+ errors,
110
+ )
111
+ if path is not None:
112
+ required.add(path)
113
+ elif (
114
+ schema_version == 1
115
+ and "h5_live_video" in requested
116
+ and ir.get("camera", {}).get("h264", {}).get("available") is not True
117
+ ):
118
+ errors.append(
119
+ "schema v1 can only export the legacy H.264 video contract; "
120
+ "migrate Hardware IR to schema v2 for MJPEG or H.265"
121
+ )
122
+
123
+ if export:
124
+ artifacts = ir.get("build_evidence", {}).get("artifacts")
125
+ if not isinstance(artifacts, list) or not artifacts:
126
+ errors.append(
127
+ "hardware-ir.json build_evidence.artifacts must retain at least "
128
+ "one verified deliverable for export"
129
+ )
130
+ else:
131
+ for index, record in enumerate(artifacts):
132
+ if not isinstance(record, dict):
133
+ errors.append(
134
+ f"build_evidence.artifacts[{index}] must be an object"
135
+ )
136
+ continue
137
+ path = project_file(
138
+ project,
139
+ record.get("path"),
140
+ f"build_evidence.artifacts[{index}].path",
141
+ errors,
142
+ )
143
+ if path is not None:
144
+ required.add(path)
145
+
146
+ for config_name in ("sdkconfig.defaults", "sdkconfig"):
147
+ config = project / config_name
148
+ if not config.is_file():
149
+ continue
150
+ text = config.read_text(encoding="utf-8", errors="replace")
151
+ match = PARTITION_FILE_RE.search(text)
152
+ if match:
153
+ path = project_file(
154
+ project,
155
+ match.group(1),
156
+ f"{config_name} custom partition table",
157
+ errors,
158
+ )
159
+ if path is not None:
160
+ required.add(path)
161
+ return required
162
+
163
+
164
+ def git_root(project: Path) -> Path | None:
165
+ result = subprocess.run(
166
+ ["git", "-C", str(project), "rev-parse", "--show-toplevel"],
167
+ stdout=subprocess.PIPE,
168
+ stderr=subprocess.DEVNULL,
169
+ text=True,
170
+ check=False,
171
+ )
172
+ if result.returncode != 0:
173
+ return None
174
+ return Path(result.stdout.strip()).resolve()
175
+
176
+
177
+ def git_ignored(root: Path, path: Path) -> bool:
178
+ try:
179
+ relative = path.relative_to(root)
180
+ except ValueError:
181
+ return False
182
+ result = subprocess.run(
183
+ ["git", "-C", str(root), "check-ignore", "--quiet", "--", relative.as_posix()],
184
+ stdout=subprocess.DEVNULL,
185
+ stderr=subprocess.DEVNULL,
186
+ check=False,
187
+ )
188
+ return result.returncode == 0
189
+
190
+
29
191
  def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
30
192
  project = project_path.expanduser().resolve()
31
193
  errors: list[str] = []
32
194
  if not (project / "CMakeLists.txt").is_file():
33
195
  return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
34
- if not (project / "dependencies.lock").is_file():
35
- errors.append("dependencies.lock is missing")
196
+ required = required_project_files(project, export, errors)
197
+ for path in sorted(required):
198
+ if not path.is_file():
199
+ errors.append(
200
+ f"required portable input is missing: {path.relative_to(project)}"
201
+ )
36
202
  sdk_required = (
37
203
  project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
38
204
  project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
@@ -43,6 +209,15 @@ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
43
209
  errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
44
210
  if export and (project / "build").exists():
45
211
  errors.append("export source contains a machine-bound build/ directory")
212
+ if export:
213
+ root = git_root(project)
214
+ if root is not None:
215
+ for path in sorted(required):
216
+ if path.is_file() and git_ignored(root, path):
217
+ errors.append(
218
+ "required portable input is ignored by Git: "
219
+ f"{path.relative_to(project)}"
220
+ )
46
221
 
47
222
  for path in source_files(project):
48
223
  relative = path.relative_to(project)
@@ -37,6 +37,30 @@ VIDEO_PROFILES = {
37
37
  "h264": ("TIRTC_VIDEO_H264", "annex_b_access_unit"),
38
38
  "h265": ("TIRTC_VIDEO_H265", "annex_b_access_unit"),
39
39
  }
40
+ BUSINESS_FEATURES = {"device_call", "wechat_voip"}
41
+ BUSINESS_PROTOCOL_TOKENS = {
42
+ "device_call": (
43
+ "/v1/call/request",
44
+ "/v1/call/device/info",
45
+ "/v1/call/reject",
46
+ "/v1/call/cancel",
47
+ "/v1/call/hangup",
48
+ "/v1/call/room",
49
+ "call_incoming",
50
+ "room_cancel",
51
+ "call_reject",
52
+ ),
53
+ "wechat_voip": (
54
+ "/v1/voip/device/profile",
55
+ "/v1/voip/device/contacts",
56
+ "/v1/voip/device/call",
57
+ "call_incoming",
58
+ "callers_update",
59
+ "wx_call_id",
60
+ "wxcall",
61
+ ),
62
+ }
63
+ GIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
40
64
 
41
65
 
42
66
  def sha256_file(path: Path) -> str:
@@ -150,6 +174,89 @@ def check_platform_contract(platform: Any, errors: list[str]) -> None:
150
174
  )
151
175
 
152
176
 
177
+ def check_business_contract(
178
+ contract: dict[str, Any],
179
+ project: Path,
180
+ errors: list[str],
181
+ inputs: dict[str, str],
182
+ ) -> set[str]:
183
+ business = contract.get("business")
184
+ if business is None:
185
+ return set()
186
+ if not isinstance(business, dict):
187
+ errors.append("business must be an object")
188
+ return set()
189
+ features = business.get("features", [])
190
+ if not isinstance(features, list) or any(
191
+ not isinstance(feature, str) for feature in features
192
+ ):
193
+ errors.append("business.features must be an array of feature names")
194
+ return set()
195
+ selected = set(features)
196
+ unknown = selected - BUSINESS_FEATURES
197
+ if unknown:
198
+ errors.append(
199
+ "business.features contains unsupported features: "
200
+ + ", ".join(sorted(unknown))
201
+ )
202
+ if not selected:
203
+ return set()
204
+
205
+ revision = business.get("protocol_revision")
206
+ if not isinstance(revision, str) or not GIT_SHA_RE.fullmatch(revision):
207
+ errors.append(
208
+ "business.protocol_revision must pin the 40-character tirtc-server-example commit"
209
+ )
210
+ arbiter = business.get("session_arbiter")
211
+ if not isinstance(arbiter, dict):
212
+ errors.append("business.session_arbiter must be an object")
213
+ else:
214
+ for field in (
215
+ "single_foreground_owner",
216
+ "generation_guard",
217
+ "monotonic_deadlines",
218
+ "deferred_lifecycle",
219
+ "restore_h5_after_call",
220
+ ):
221
+ if arbiter.get(field) is not True:
222
+ errors.append(f"business.session_arbiter.{field} must be true")
223
+ if arbiter.get("pending_capacity") != 1:
224
+ errors.append("business.session_arbiter.pending_capacity must be 1")
225
+
226
+ assertions = business.get("implementation_assertions")
227
+ combined = ""
228
+ if not isinstance(assertions, list) or not assertions:
229
+ errors.append("business.implementation_assertions must be a non-empty array")
230
+ else:
231
+ for index, assertion in enumerate(assertions):
232
+ label = f"business.implementation_assertions[{index}]"
233
+ if not isinstance(assertion, dict):
234
+ errors.append(f"{label} must be an object")
235
+ continue
236
+ try:
237
+ source = project_file(project, assertion.get("file"), f"{label}.file")
238
+ except ValueError as exc:
239
+ errors.append(str(exc))
240
+ continue
241
+ if not source.is_file():
242
+ errors.append(f"{label}.file does not exist: {source}")
243
+ continue
244
+ text = source.read_text(encoding="utf-8", errors="replace")
245
+ combined += "\n" + text
246
+ inputs[str(source.relative_to(project))] = sha256_file(source)
247
+ for token in assertion.get("contains", []):
248
+ if not isinstance(token, str) or not token:
249
+ errors.append(f"{label}.contains must contain non-empty strings")
250
+ elif token not in text:
251
+ errors.append(f"{label} is missing token: {token}")
252
+
253
+ for feature in sorted(selected & BUSINESS_FEATURES):
254
+ for token in BUSINESS_PROTOCOL_TOKENS[feature]:
255
+ if token not in combined:
256
+ errors.append(f"business {feature} is missing protocol token: {token}")
257
+ return selected & BUSINESS_FEATURES
258
+
259
+
153
260
  def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
154
261
  project = project_path.expanduser().resolve()
155
262
  contract_file = contract_path.expanduser().resolve()
@@ -275,9 +382,23 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
275
382
  ):
276
383
  errors.append("AI media must start only from the validated command handler")
277
384
 
385
+ base_errors = list(errors)
386
+ business_errors: list[str] = []
387
+ business_features = check_business_contract(
388
+ contract, project, business_errors, inputs
389
+ )
390
+ errors.extend(business_errors)
391
+
278
392
  return {
279
393
  "ok": not errors,
280
- "summary": "endpoint + callbacks + H5/AI stream and negotiation contract",
394
+ "base_ok": not base_errors,
395
+ "business_ok": not business_errors,
396
+ "summary": (
397
+ "endpoint + callbacks + H5/AI stream and negotiation contract; "
398
+ f"business={','.join(sorted(business_features)) or 'none'}"
399
+ ),
400
+ "business_features": sorted(business_features),
401
+ "business_errors": business_errors,
281
402
  "inputs": inputs,
282
403
  "errors": errors,
283
404
  }