its-magic 0.1.2-9 → 0.1.3-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 (237) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +93 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +1036 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +312 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +523 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/CHANGELOG.md +11 -0
  65. package/template/README.md +1602 -0
  66. package/template/decisions/DEC-0001.md +11 -0
  67. package/template/decisions/DEC-0002.md +11 -0
  68. package/template/docs/developer/README.md +44 -0
  69. package/template/docs/engineering/architecture.md +9 -0
  70. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  71. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  72. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  73. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  74. package/template/docs/engineering/compatibility-report.md +20 -0
  75. package/template/docs/engineering/compatibility-signals.md +7 -0
  76. package/template/docs/engineering/component-scope-report.md +16 -0
  77. package/template/docs/engineering/component-scope.md +19 -0
  78. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  79. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  80. package/template/docs/engineering/decisions.md +16 -0
  81. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  82. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  83. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  84. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  85. package/template/docs/engineering/phase-context.md +16 -0
  86. package/template/docs/engineering/release-targets.json +123 -0
  87. package/template/docs/engineering/research.md +29 -0
  88. package/template/docs/engineering/runbook.md +2391 -0
  89. package/template/docs/engineering/runtime-connectivity.md +81 -0
  90. package/template/docs/engineering/security-review.md +33 -0
  91. package/template/docs/engineering/spec-pack/README.md +20 -0
  92. package/template/docs/engineering/state-archive/README.md +15 -0
  93. package/template/docs/engineering/state.md +20 -0
  94. package/template/docs/engineering/status-normalization-report.md +19 -0
  95. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  96. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  97. package/template/docs/product/acceptance.md +1 -0
  98. package/template/docs/product/backlog.md +5 -0
  99. package/template/docs/product/vision.md +11 -0
  100. package/template/docs/user-guides/README.md +40 -0
  101. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  102. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  103. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  104. package/template/handoffs/release_notes.md +51 -0
  105. package/template/handoffs/release_queue.md +47 -0
  106. package/template/handoffs/release_to_dev.md +31 -0
  107. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  108. package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
  109. package/template/handoffs/resume_brief.md +14 -0
  110. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  111. package/template/handoffs/token_cost_runs/README.md +26 -0
  112. package/template/its_magic/.its-magic-version +1 -0
  113. package/template/its_magic/README.md +9 -0
  114. package/template/scripts/auto_outer_driver.py +521 -0
  115. package/template/scripts/caveman_compress_input.py +903 -0
  116. package/template/scripts/check_downstream_ci_guard.py +67 -0
  117. package/template/scripts/check_intake_template_parity.py +312 -0
  118. package/template/scripts/check_token_cost_parity.py +69 -0
  119. package/template/scripts/dev_environment_lib.py +601 -0
  120. package/template/scripts/doc_profile_lib.py +415 -0
  121. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  122. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  123. package/template/scripts/guard_installer_publish.py +88 -0
  124. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  125. package/template/scripts/intake_bug_routing_guard.py +67 -0
  126. package/template/scripts/intake_evidence_lib.py +802 -0
  127. package/template/scripts/intake_evidence_validate.py +73 -0
  128. package/template/scripts/materialize_codebase_map.py +184 -0
  129. package/template/scripts/pack_json_validate.py +130 -0
  130. package/template/scripts/project_readme_coverage_lib.py +417 -0
  131. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  132. package/{scripts → template/scripts}/release-all.sh +27 -4
  133. package/template/scripts/release_changelog_backfill.py +153 -0
  134. package/template/scripts/release_changelog_lib.py +544 -0
  135. package/template/scripts/release_changelog_validate.py +134 -0
  136. package/template/scripts/remote_config_summary.py +243 -0
  137. package/template/scripts/sync_push_gates.py +198 -0
  138. package/template/scripts/token_cost_compare.py +40 -0
  139. package/template/scripts/token_cost_lib.py +108 -0
  140. package/template/scripts/uat_probe_lib.py +868 -0
  141. package/template/scripts/validate-and-push.ps1 +280 -0
  142. package/template/scripts/validate-and-push.sh +243 -0
  143. package/template/scripts/validate_doc_profile.py +103 -0
  144. package/template/scripts/validate_project_readme_coverage.py +151 -0
  145. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  146. package/template/sprints/S0001/progress.md +1 -0
  147. package/template/sprints/S0001/qa-findings.md +9 -0
  148. package/template/sprints/S0001/release-findings.md +24 -0
  149. package/template/sprints/S0001/sprint.md +9 -0
  150. package/template/sprints/S0001/summary.md +7 -0
  151. package/template/sprints/S0001/tasks.md +1 -0
  152. package/template/sprints/S0001/uat.json +6 -0
  153. package/template/sprints/S0001/uat.md +5 -0
  154. package/template/sprints/quick/Q0001/summary.md +1 -0
  155. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  156. package/.cursor/agents/curator.mdc +0 -21
  157. package/.cursor/agents/dev.mdc +0 -20
  158. package/.cursor/agents/po.mdc +0 -19
  159. package/.cursor/agents/qa.mdc +0 -19
  160. package/.cursor/agents/release.mdc +0 -19
  161. package/.cursor/agents/tech-lead.mdc +0 -21
  162. package/.cursor/commands/gsd-architecture.md +0 -29
  163. package/.cursor/commands/gsd-auto.md +0 -27
  164. package/.cursor/commands/gsd-discovery.md +0 -27
  165. package/.cursor/commands/gsd-execute.md +0 -32
  166. package/.cursor/commands/gsd-intake.md +0 -28
  167. package/.cursor/commands/gsd-map-codebase.md +0 -25
  168. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  169. package/.cursor/commands/gsd-milestone-start.md +0 -26
  170. package/.cursor/commands/gsd-pause.md +0 -25
  171. package/.cursor/commands/gsd-plan-verify.md +0 -26
  172. package/.cursor/commands/gsd-qa.md +0 -28
  173. package/.cursor/commands/gsd-quick.md +0 -24
  174. package/.cursor/commands/gsd-refresh-context.md +0 -26
  175. package/.cursor/commands/gsd-release.md +0 -29
  176. package/.cursor/commands/gsd-research.md +0 -28
  177. package/.cursor/commands/gsd-resume.md +0 -26
  178. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  179. package/.cursor/commands/gsd-verify-work.md +0 -25
  180. package/.cursor/hooks.json +0 -26
  181. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  182. package/.cursor/remote.json +0 -18
  183. package/.cursor/rules/gsd-core.mdc +0 -18
  184. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  185. package/.cursor/rules/gsd-quality.mdc +0 -15
  186. package/.cursor/scratchpad.md +0 -34
  187. package/.github/workflows/ci.yml +0 -47
  188. package/decisions/DEC-0001.md +0 -21
  189. package/decisions/DEC-0002.md +0 -21
  190. package/docs/engineering/architecture.md +0 -354
  191. package/docs/engineering/decisions.md +0 -6
  192. package/docs/engineering/research.md +0 -11
  193. package/docs/engineering/runbook.md +0 -32
  194. package/docs/engineering/state.md +0 -33
  195. package/docs/product/acceptance.md +0 -6
  196. package/docs/product/backlog.md +0 -7
  197. package/docs/product/vision.md +0 -46
  198. package/gsd-installer.ps1 +0 -189
  199. package/gsd-installer.py +0 -195
  200. package/gsd-installer.sh +0 -201
  201. package/handoffs/release_notes.md +0 -14
  202. package/handoffs/resume_brief.md +0 -8
  203. package/milestones/M0001/milestone.json +0 -7
  204. package/milestones/M0001/phases.json +0 -9
  205. package/milestones/M0001/progress.md +0 -3
  206. package/milestones/M0001/summary.md +0 -3
  207. package/scripts/generate-release-notes.ps1 +0 -74
  208. package/scripts/generate-release-notes.sh +0 -63
  209. package/scripts/release-all.ps1 +0 -423
  210. package/sprints/S0001/progress.md +0 -4
  211. package/sprints/S0001/qa-findings.md +0 -113
  212. package/sprints/S0001/sprint.md +0 -70
  213. package/sprints/S0001/summary.md +0 -46
  214. package/sprints/S0001/tasks.md +0 -35
  215. package/sprints/S0001/uat.json +0 -8
  216. package/sprints/S0001/uat.md +0 -8
  217. package/sprints/quick/Q0001/summary.md +0 -3
  218. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  230. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  231. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  232. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  233. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  234. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  235. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  236. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  237. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,868 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ UAT probe resolver (US-0092 / DEC-0078; US-0093 / DEC-0079).
4
+
5
+ Shared by /verify-work and /qa for self-verify acceptance steps.
6
+ Fail-closed — no silent PASS. Lib never invokes browser MCP (BUG-0006).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+ UAT_PROBE_UNRESOLVED = "UAT_PROBE_UNRESOLVED"
22
+ UAT_STACK_PROFILE_UNKNOWN = "UAT_STACK_PROFILE_UNKNOWN"
23
+ UAT_PROBE_TIMEOUT = "UAT_PROBE_TIMEOUT"
24
+ UAT_PROBE_FAILED = "UAT_PROBE_FAILED"
25
+ UAT_PROBE_FORBIDDEN = "UAT_PROBE_FORBIDDEN"
26
+ UAT_PROBE_PASS = "UAT_PROBE_PASS"
27
+ UAT_BROWSER_UNAVAILABLE = "UAT_BROWSER_UNAVAILABLE"
28
+ UAT_BROWSER_PROBE_FAILED = "UAT_BROWSER_PROBE_FAILED"
29
+ UAT_BROWSER_PROBE_TIMEOUT = "UAT_BROWSER_PROBE_TIMEOUT"
30
+
31
+ PROBE_KINDS = (
32
+ "build",
33
+ "test",
34
+ "api_health",
35
+ "process_health",
36
+ "browser_smoke",
37
+ "cli_smoke",
38
+ "manual_operator",
39
+ )
40
+
41
+ FORBIDDEN_PATH_TOKENS = (".env", "intake_evidence", "handoffs/intake_evidence")
42
+ SECRET_FORBIDDEN_TOKENS = ("password", "credential", "api key")
43
+
44
+ JUDGMENT_DENY_TOKENS = (
45
+ "visually",
46
+ "aesthetically",
47
+ "operator confirms",
48
+ "subjective",
49
+ "human judgment",
50
+ "eyeball",
51
+ "manually verify appearance",
52
+ "approve layout",
53
+ )
54
+
55
+ AUTOMATABLE_UI_TOKENS = (
56
+ "click",
57
+ "fill",
58
+ "navigate",
59
+ "smoke",
60
+ "form",
61
+ "submit",
62
+ "button",
63
+ "page load",
64
+ "scroll",
65
+ "type into",
66
+ "select",
67
+ "checkbox",
68
+ "dropdown",
69
+ "ui",
70
+ "browser",
71
+ )
72
+
73
+ BROWSER_PROBE_MODES = ("cursor", "http_fallback", "playwright_fallback")
74
+
75
+ DEFAULT_PROBE_TIMEOUT = 120
76
+ DEFAULT_POLL_SECONDS = 60
77
+ DEFAULT_POLL_INTERVAL = 2
78
+ MAX_SCREENSHOTS = 5
79
+
80
+
81
+ def _merge_scratchpad(repo: Path) -> dict[str, str]:
82
+ values: dict[str, str] = {}
83
+ for name in (".cursor/scratchpad.md", ".cursor/scratchpad.local.md"):
84
+ path = repo / name
85
+ if not path.is_file():
86
+ continue
87
+ for line in path.read_text(encoding="utf-8").splitlines():
88
+ stripped = line.strip()
89
+ if not stripped or stripped.startswith("#"):
90
+ continue
91
+ if "=" in stripped:
92
+ key, _, val = stripped.partition("=")
93
+ values[key.strip()] = val.strip()
94
+ return values
95
+
96
+
97
+ def _read_int(merged: dict[str, str], key: str, default: int) -> int:
98
+ raw = merged.get(key, "")
99
+ if not raw:
100
+ return default
101
+ try:
102
+ return max(1, int(raw))
103
+ except ValueError:
104
+ return default
105
+
106
+
107
+ def read_browser_probe_mode(repo: Path) -> str:
108
+ merged = _merge_scratchpad(repo)
109
+ mode = merged.get("UAT_BROWSER_PROBE_MODE", "cursor").strip()
110
+ return mode if mode in BROWSER_PROBE_MODES else "cursor"
111
+
112
+
113
+ def read_fallback_chain(repo: Path) -> bool:
114
+ merged = _merge_scratchpad(repo)
115
+ raw = merged.get("UAT_BROWSER_FALLBACK_CHAIN", "1").strip()
116
+ if raw in ("0", "1"):
117
+ return raw == "1"
118
+ return True
119
+
120
+
121
+ def read_poll_settings(repo: Path) -> tuple[int, int]:
122
+ merged = _merge_scratchpad(repo)
123
+ cap = _read_int(merged, "UAT_PROCESS_HEALTH_POLL_SECONDS", DEFAULT_POLL_SECONDS)
124
+ interval = _read_int(
125
+ merged, "UAT_PROCESS_HEALTH_POLL_INTERVAL_SECONDS", DEFAULT_POLL_INTERVAL
126
+ )
127
+ return cap, interval
128
+
129
+
130
+ def detect_stack_profile(repo: Path) -> str | None:
131
+ if (repo / "package.json").is_file():
132
+ return "node"
133
+ if (repo / "pyproject.toml").is_file() or (repo / "setup.py").is_file():
134
+ return "python"
135
+ if (repo / "go.mod").is_file():
136
+ return "go"
137
+ if list(repo.glob("*.csproj")):
138
+ return "dotnet"
139
+ if (repo / "pom.xml").is_file():
140
+ return "java"
141
+ readme = repo / "README.md"
142
+ if readme.is_file() and "generated" in readme.read_text(encoding="utf-8").lower():
143
+ return "generated"
144
+ return None
145
+
146
+
147
+ def _forbidden(step_text: str) -> bool:
148
+ lower = step_text.lower()
149
+ if any(tok in lower for tok in FORBIDDEN_PATH_TOKENS):
150
+ return True
151
+ return any(tok in lower for tok in SECRET_FORBIDDEN_TOKENS)
152
+
153
+
154
+ def _has_judgment_deny(lower: str) -> bool:
155
+ return any(tok in lower for tok in JUDGMENT_DENY_TOKENS)
156
+
157
+
158
+ def _has_automatable_ui(lower: str) -> bool:
159
+ return any(tok in lower for tok in AUTOMATABLE_UI_TOKENS)
160
+
161
+
162
+ def _read_test_command(repo: Path) -> str | None:
163
+ runbook = repo / "docs" / "engineering" / "runbook.md"
164
+ if runbook.is_file():
165
+ m = re.search(r"^TEST_COMMAND:\s*(.+)$", runbook.read_text(encoding="utf-8"), re.M)
166
+ if m:
167
+ cmd = m.group(1).strip()
168
+ if cmd and "not configured" not in cmd.lower():
169
+ return cmd
170
+ merged = _merge_scratchpad(repo)
171
+ return merged.get("TEST_COMMAND") or None
172
+
173
+
174
+ def _read_build_command(repo: Path, profile: str | None) -> str | None:
175
+ if profile == "node" and (repo / "package.json").is_file():
176
+ try:
177
+ pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
178
+ scripts = pkg.get("scripts", {})
179
+ if "build" in scripts:
180
+ return "npm run build"
181
+ except (json.JSONDecodeError, OSError):
182
+ pass
183
+ if profile == "python" and (repo / "pyproject.toml").is_file():
184
+ return "python -m build"
185
+ return None
186
+
187
+
188
+ def _read_health_url(repo: Path) -> str | None:
189
+ rc = repo / "docs" / "engineering" / "runtime-connectivity.md"
190
+ if rc.is_file():
191
+ m = re.search(r"https?://[^\s\)]+", rc.read_text(encoding="utf-8"))
192
+ if m:
193
+ return m.group(0).rstrip(".,)")
194
+ return None
195
+
196
+
197
+ def resolve_browser_url(repo: Path) -> str | None:
198
+ url = _read_health_url(repo)
199
+ if url:
200
+ return url
201
+ merged = _merge_scratchpad(repo)
202
+ port = merged.get("DEV_SERVER_PORT", "").strip()
203
+ if port:
204
+ return f"http://localhost:{port}/"
205
+ if (repo / "package.json").is_file():
206
+ try:
207
+ pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
208
+ if pkg.get("scripts", {}).get("start") or pkg.get("scripts", {}).get("dev"):
209
+ default_port = port or "3000"
210
+ return f"http://localhost:{default_port}/"
211
+ except (json.JSONDecodeError, OSError):
212
+ pass
213
+ return None
214
+
215
+
216
+ def is_mcp_unavailable() -> bool:
217
+ ci = os.environ.get("CI", "").lower()
218
+ if ci in ("1", "true", "yes"):
219
+ return True
220
+ return os.environ.get("GITHUB_ACTIONS", "").lower() == "true"
221
+
222
+
223
+ def classify_step(step_text: str, repo: Path) -> tuple[str | None, str]:
224
+ if _forbidden(step_text):
225
+ return None, UAT_PROBE_FORBIDDEN
226
+ lower = step_text.lower()
227
+ profile = detect_stack_profile(repo)
228
+
229
+ if _has_judgment_deny(lower):
230
+ return "manual_operator", UAT_PROBE_UNRESOLVED
231
+
232
+ if any(w in lower for w in ("build", "compile", "bundle")):
233
+ if _read_build_command(repo, profile):
234
+ return "build", ""
235
+ return None, UAT_PROBE_UNRESOLVED
236
+
237
+ if any(w in lower for w in ("test", "pytest", "unit test", "integration test")):
238
+ if _read_test_command(repo) or profile in ("python", "node", "go", "generated"):
239
+ return "test", ""
240
+ return None, UAT_PROBE_UNRESOLVED
241
+
242
+ if any(w in lower for w in ("api", "health", "endpoint", "http", "rest")):
243
+ if _read_health_url(repo):
244
+ return "api_health", ""
245
+ return None, UAT_PROBE_UNRESOLVED
246
+
247
+ if any(w in lower for w in ("process", "startup", "server start", "readiness")):
248
+ if _extract_startup_command(step_text, repo) and (
249
+ _read_health_url(repo) or resolve_browser_url(repo)
250
+ ):
251
+ return "process_health", ""
252
+ return "process_health", UAT_PROBE_UNRESOLVED
253
+
254
+ if _has_automatable_ui(lower):
255
+ if resolve_browser_url(repo):
256
+ return "browser_smoke", ""
257
+ return None, UAT_PROBE_UNRESOLVED
258
+
259
+ if any(w in lower for w in ("browser", "playwright", "smoke", "ui")):
260
+ if resolve_browser_url(repo):
261
+ return "browser_smoke", ""
262
+ return None, UAT_PROBE_UNRESOLVED
263
+
264
+ if any(w in lower for w in ("cli", "command line", "exit code")):
265
+ if _extract_backtick_command(step_text):
266
+ return "cli_smoke", ""
267
+ return "cli_smoke", UAT_PROBE_UNRESOLVED
268
+
269
+ if any(w in lower for w in ("manual", "operator", "human", "judgment")):
270
+ return "manual_operator", UAT_PROBE_UNRESOLVED
271
+
272
+ if profile is None:
273
+ return None, UAT_STACK_PROFILE_UNKNOWN
274
+ return None, UAT_PROBE_UNRESOLVED
275
+
276
+
277
+ def _extract_backtick_command(text: str) -> str | None:
278
+ m = re.search(r"`([^`]+)`", text)
279
+ return m.group(1).strip() if m else None
280
+
281
+
282
+ def _extract_stdout_expectation(text: str) -> str | None:
283
+ m = re.search(r'expect\s+"([^"]+)"', text, re.I)
284
+ if m:
285
+ return m.group(1)
286
+ m = re.search(r'output contains\s+"([^"]+)"', text, re.I)
287
+ return m.group(1) if m else None
288
+
289
+
290
+ def _extract_startup_command(step_text: str, repo: Path) -> str | None:
291
+ merged = _merge_scratchpad(repo)
292
+ override = merged.get("DEV_SERVER_COMMAND", "").strip()
293
+ if override:
294
+ return override
295
+ cmd = _extract_backtick_command(step_text)
296
+ if cmd:
297
+ return cmd
298
+ m = re.search(r'"([^"]+(?:start|dev|serve)[^"]*)"', step_text, re.I)
299
+ if m:
300
+ return m.group(1)
301
+ m = re.search(
302
+ r"(npm\s+(?:run\s+)?(?:start|dev)|yarn\s+(?:start|dev)|python\s+-m\s+\w+)",
303
+ step_text,
304
+ re.I,
305
+ )
306
+ if m:
307
+ return m.group(1)
308
+ profile = detect_stack_profile(repo)
309
+ if profile == "node" and (repo / "package.json").is_file():
310
+ try:
311
+ pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
312
+ scripts = pkg.get("scripts", {})
313
+ if "dev" in scripts:
314
+ return "npm run dev"
315
+ if "start" in scripts:
316
+ return "npm run start"
317
+ except (json.JSONDecodeError, OSError):
318
+ pass
319
+ return None
320
+
321
+
322
+ def _http_get_probe(url: str, timeout: int) -> tuple[bool, str, dict[str, object]]:
323
+ meta: dict[str, object] = {"url": url}
324
+ try:
325
+ req = urllib.request.Request(url, method="GET")
326
+ with urllib.request.urlopen(req, timeout=min(timeout, 30)) as resp:
327
+ meta["status_code"] = resp.status
328
+ if 200 <= resp.status < 400:
329
+ return True, UAT_PROBE_PASS, meta
330
+ return False, UAT_BROWSER_PROBE_FAILED, meta
331
+ except urllib.error.URLError as exc:
332
+ meta["error"] = type(exc).__name__
333
+ return False, UAT_BROWSER_PROBE_FAILED, meta
334
+ except TimeoutError:
335
+ return False, UAT_BROWSER_PROBE_TIMEOUT, meta
336
+
337
+
338
+ def _playwright_probe(url: str, timeout: int) -> tuple[bool, str, dict[str, object]]:
339
+ meta: dict[str, object] = {"url": url}
340
+ script = (
341
+ "const { chromium } = require('playwright');"
342
+ "(async () => {"
343
+ f" const b = await chromium.launch(); const p = await b.newPage();"
344
+ f" await p.goto({json.dumps(url)}, {{ timeout: {min(timeout, 30) * 1000} }});"
345
+ " await b.close(); process.exit(0);"
346
+ "})().catch(() => process.exit(1));"
347
+ )
348
+ try:
349
+ proc = subprocess.run(
350
+ ["node", "-e", script],
351
+ capture_output=True,
352
+ text=True,
353
+ timeout=min(timeout, 60),
354
+ )
355
+ meta["exit_code"] = proc.returncode
356
+ if proc.returncode == 0:
357
+ return True, UAT_PROBE_PASS, meta
358
+ return False, UAT_BROWSER_PROBE_FAILED, meta
359
+ except subprocess.TimeoutExpired:
360
+ return False, UAT_BROWSER_PROBE_TIMEOUT, meta
361
+ except OSError as exc:
362
+ meta["error"] = str(exc)
363
+ return False, UAT_BROWSER_UNAVAILABLE, meta
364
+
365
+
366
+ def _run_fallback_chain(
367
+ url: str,
368
+ repo: Path,
369
+ timeout: int,
370
+ *,
371
+ dom_interaction: bool = False,
372
+ ) -> tuple[bool, str, dict[str, object], str]:
373
+ mode = read_browser_probe_mode(repo)
374
+ chain = read_fallback_chain(repo)
375
+ details: dict[str, object] = {"url": url, "execution_tier": "stdlib"}
376
+
377
+ if mode == "http_fallback":
378
+ ok, code, meta = _http_get_probe(url, timeout)
379
+ details.update(meta)
380
+ return ok, code, details, "http_fallback"
381
+
382
+ if mode == "playwright_fallback":
383
+ ok, code, meta = _playwright_probe(url, timeout)
384
+ details.update(meta)
385
+ if ok:
386
+ return ok, code, details, "playwright_fallback"
387
+ if chain:
388
+ ok, code, meta = _http_get_probe(url, timeout)
389
+ details.update(meta)
390
+ details["fallback_from"] = "playwright_fallback"
391
+ return ok, code, details, "http_fallback"
392
+ return ok, UAT_BROWSER_UNAVAILABLE, details, "playwright_fallback"
393
+
394
+ if dom_interaction and chain:
395
+ ok, code, meta = _playwright_probe(url, timeout)
396
+ if ok:
397
+ details.update(meta)
398
+ return ok, code, details, "playwright_fallback"
399
+
400
+ ok, code, meta = _http_get_probe(url, timeout)
401
+ details.update(meta)
402
+ if ok:
403
+ return ok, code, details, "http_fallback"
404
+ if chain and dom_interaction:
405
+ ok, code, meta = _playwright_probe(url, timeout)
406
+ details.update(meta)
407
+ details["fallback_from"] = "http_fallback"
408
+ return ok, code, details, "playwright_fallback"
409
+ return ok, code, details, "http_fallback"
410
+
411
+
412
+ def execute_browser_smoke(
413
+ step_text: str,
414
+ repo: Path,
415
+ *,
416
+ timeout: int = DEFAULT_PROBE_TIMEOUT,
417
+ ) -> dict[str, object]:
418
+ mode = read_browser_probe_mode(repo)
419
+ url = resolve_browser_url(repo)
420
+ dom_interaction = _has_automatable_ui(step_text.lower())
421
+ result: dict[str, object] = {
422
+ "probe_kind": "browser_smoke",
423
+ "step": step_text[:200],
424
+ "stack_profile": detect_stack_profile(repo) or "unknown",
425
+ "probe_mode": mode,
426
+ "passed": False,
427
+ "reason_code": UAT_PROBE_UNRESOLVED,
428
+ "target_url": url,
429
+ }
430
+
431
+ if not url:
432
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
433
+ return result
434
+
435
+ if mode == "cursor":
436
+ if is_mcp_unavailable():
437
+ result["reason_code"] = UAT_BROWSER_UNAVAILABLE
438
+ result["execution_tier"] = "stdlib"
439
+ if read_fallback_chain(repo):
440
+ ok, code, meta, fb_mode = _run_fallback_chain(
441
+ url, repo, timeout, dom_interaction=dom_interaction
442
+ )
443
+ result.update(meta)
444
+ result["probe_mode"] = fb_mode
445
+ result["passed"] = ok
446
+ result["reason_code"] = code if ok else UAT_BROWSER_PROBE_FAILED
447
+ if not ok and code == UAT_BROWSER_UNAVAILABLE:
448
+ result["reason_code"] = UAT_BROWSER_UNAVAILABLE
449
+ return result
450
+ result["execution_tier"] = "agent"
451
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
452
+ result["agent_plan"] = {
453
+ "sequence": "browser_navigate → interact → screenshot → console/network → evidence",
454
+ "evidence_dir": "sprints/Sxxxx/evidence/browser/",
455
+ }
456
+ return result
457
+
458
+ result["execution_tier"] = "stdlib"
459
+ ok, code, meta, fb_mode = _run_fallback_chain(
460
+ url, repo, timeout, dom_interaction=dom_interaction
461
+ )
462
+ result.update(meta)
463
+ result["probe_mode"] = fb_mode
464
+ result["passed"] = ok
465
+ result["reason_code"] = code
466
+ return result
467
+
468
+
469
+ def execute_process_health(
470
+ step_text: str,
471
+ repo: Path,
472
+ *,
473
+ timeout: int = DEFAULT_PROBE_TIMEOUT,
474
+ poll_cap: int | None = None,
475
+ poll_interval: int | None = None,
476
+ ) -> dict[str, object]:
477
+ cap, interval = read_poll_settings(repo)
478
+ if poll_cap is not None:
479
+ cap = poll_cap
480
+ if poll_interval is not None:
481
+ interval = poll_interval
482
+
483
+ startup = _extract_startup_command(step_text, repo)
484
+ health_url = _read_health_url(repo) or resolve_browser_url(repo)
485
+ result: dict[str, object] = {
486
+ "probe_kind": "process_health",
487
+ "step": step_text[:200],
488
+ "stack_profile": detect_stack_profile(repo) or "unknown",
489
+ "execution_tier": "stdlib",
490
+ "passed": False,
491
+ "reason_code": UAT_PROBE_UNRESOLVED,
492
+ }
493
+
494
+ if not startup or not health_url:
495
+ return result
496
+
497
+ result["startup_command"] = startup
498
+ result["health_url"] = health_url
499
+ proc: subprocess.Popen[str] | None = None
500
+ try:
501
+ proc = subprocess.Popen(
502
+ startup,
503
+ shell=True,
504
+ cwd=str(repo),
505
+ stdout=subprocess.DEVNULL,
506
+ stderr=subprocess.DEVNULL,
507
+ )
508
+ deadline = time.monotonic() + cap
509
+ while time.monotonic() < deadline:
510
+ ok, code, meta = _http_get_probe(health_url, min(timeout, interval + 2))
511
+ if ok:
512
+ result.update(meta)
513
+ result["passed"] = True
514
+ result["reason_code"] = UAT_PROBE_PASS
515
+ return result
516
+ time.sleep(interval)
517
+ result["reason_code"] = UAT_PROBE_TIMEOUT
518
+ except OSError as exc:
519
+ result["reason_code"] = UAT_PROBE_FAILED
520
+ result["error"] = str(exc)
521
+ finally:
522
+ if proc is not None:
523
+ proc.terminate()
524
+ try:
525
+ proc.wait(timeout=5)
526
+ except subprocess.TimeoutExpired:
527
+ proc.kill()
528
+ return result
529
+
530
+
531
+ def execute_cli_smoke(
532
+ step_text: str,
533
+ repo: Path,
534
+ *,
535
+ timeout: int = DEFAULT_PROBE_TIMEOUT,
536
+ ) -> dict[str, object]:
537
+ cmd = _extract_backtick_command(step_text)
538
+ expect_out = _extract_stdout_expectation(step_text)
539
+ result: dict[str, object] = {
540
+ "probe_kind": "cli_smoke",
541
+ "step": step_text[:200],
542
+ "stack_profile": detect_stack_profile(repo) or "unknown",
543
+ "execution_tier": "stdlib",
544
+ "passed": False,
545
+ "reason_code": UAT_PROBE_UNRESOLVED,
546
+ }
547
+ if not cmd:
548
+ return result
549
+ result["command"] = cmd
550
+ try:
551
+ proc = subprocess.run(
552
+ cmd,
553
+ shell=True,
554
+ cwd=str(repo),
555
+ timeout=timeout,
556
+ capture_output=True,
557
+ text=True,
558
+ )
559
+ result["exit_code"] = proc.returncode
560
+ if proc.returncode != 0:
561
+ result["reason_code"] = UAT_PROBE_FAILED
562
+ return result
563
+ if expect_out and expect_out not in proc.stdout:
564
+ result["reason_code"] = UAT_PROBE_FAILED
565
+ return result
566
+ result["reason_code"] = UAT_PROBE_PASS
567
+ result["passed"] = True
568
+ except subprocess.TimeoutExpired:
569
+ result["reason_code"] = UAT_PROBE_TIMEOUT
570
+ except OSError as exc:
571
+ result["reason_code"] = UAT_PROBE_FAILED
572
+ result["error"] = str(exc)
573
+ return result
574
+
575
+
576
+ def validate_browser_evidence(result: dict[str, object], mode: str) -> dict[str, object]:
577
+ out = dict(result)
578
+ if not out.get("passed"):
579
+ return out
580
+ if mode != "cursor":
581
+ return out
582
+ refs = out.get("browser_evidence_refs")
583
+ if not isinstance(refs, dict):
584
+ out["passed"] = False
585
+ out["reason_code"] = UAT_BROWSER_PROBE_FAILED
586
+ return out
587
+ nav = str(refs.get("navigation_url") or "").strip()
588
+ screenshots = refs.get("screenshots")
589
+ if not isinstance(screenshots, list):
590
+ screenshots = []
591
+ console = refs.get("console_summary")
592
+ network = refs.get("network_summary")
593
+ console_path = ""
594
+ network_path = ""
595
+ if isinstance(console, dict):
596
+ console_path = str(console.get("summary_path") or "")
597
+ if isinstance(network, dict):
598
+ network_path = str(network.get("summary_path") or "")
599
+ has_evidence = bool(nav) and (
600
+ bool(screenshots) or bool(console_path) or bool(network_path)
601
+ )
602
+ if len(screenshots) > MAX_SCREENSHOTS:
603
+ has_evidence = False
604
+ if not has_evidence:
605
+ out["passed"] = False
606
+ out["reason_code"] = UAT_BROWSER_PROBE_FAILED
607
+ return out
608
+
609
+
610
+ def merge_result_fragment(fragment: dict[str, object], repo: Path) -> dict[str, object]:
611
+ mode = read_browser_probe_mode(repo)
612
+ if "probe_mode" not in fragment:
613
+ fragment = dict(fragment)
614
+ fragment["probe_mode"] = mode
615
+ return validate_browser_evidence(fragment, mode)
616
+
617
+
618
+ def execute_probe(
619
+ kind: str,
620
+ step_text: str,
621
+ repo: Path,
622
+ *,
623
+ timeout: int = DEFAULT_PROBE_TIMEOUT,
624
+ ) -> dict[str, object]:
625
+ profile = detect_stack_profile(repo)
626
+ result: dict[str, object] = {
627
+ "probe_kind": kind,
628
+ "step": step_text[:200],
629
+ "stack_profile": profile or "unknown",
630
+ "reason_code": UAT_PROBE_UNRESOLVED,
631
+ "passed": False,
632
+ }
633
+
634
+ if kind == "manual_operator":
635
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
636
+ return result
637
+
638
+ if kind == "build":
639
+ cmd = _read_build_command(repo, profile)
640
+ if not cmd:
641
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
642
+ return result
643
+ result["execution_tier"] = "stdlib"
644
+ return _run_subprocess(cmd, repo, timeout, result)
645
+
646
+ if kind == "test":
647
+ cmd = _read_test_command(repo)
648
+ if not cmd:
649
+ if profile == "python":
650
+ cmd = "python -m pytest -q"
651
+ elif profile == "node":
652
+ cmd = "npm test"
653
+ elif profile == "generated":
654
+ cmd = "python -m pytest -q"
655
+ else:
656
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
657
+ return result
658
+ result["execution_tier"] = "stdlib"
659
+ return _run_subprocess(cmd, repo, timeout, result)
660
+
661
+ if kind == "api_health":
662
+ url = _read_health_url(repo)
663
+ if not url:
664
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
665
+ return result
666
+ result["execution_tier"] = "stdlib"
667
+ ok, code, meta = _http_get_probe(url, timeout)
668
+ result.update(meta)
669
+ result["passed"] = ok
670
+ result["reason_code"] = code if ok else UAT_PROBE_FAILED
671
+ return result
672
+
673
+ if kind == "process_health":
674
+ return execute_process_health(step_text, repo, timeout=timeout)
675
+
676
+ if kind == "browser_smoke":
677
+ return execute_browser_smoke(step_text, repo, timeout=timeout)
678
+
679
+ if kind == "cli_smoke":
680
+ return execute_cli_smoke(step_text, repo, timeout=timeout)
681
+
682
+ result["reason_code"] = UAT_PROBE_UNRESOLVED
683
+ return result
684
+
685
+
686
+ def _run_subprocess(
687
+ cmd: str,
688
+ repo: Path,
689
+ timeout: int,
690
+ result: dict[str, object],
691
+ ) -> dict[str, object]:
692
+ result["command"] = cmd
693
+ try:
694
+ proc = subprocess.run(
695
+ cmd,
696
+ shell=True,
697
+ cwd=str(repo),
698
+ timeout=timeout,
699
+ capture_output=True,
700
+ text=True,
701
+ )
702
+ result["exit_code"] = proc.returncode
703
+ if proc.returncode == 0:
704
+ result["reason_code"] = UAT_PROBE_PASS
705
+ result["passed"] = True
706
+ else:
707
+ result["reason_code"] = UAT_PROBE_FAILED
708
+ except subprocess.TimeoutExpired:
709
+ result["reason_code"] = UAT_PROBE_TIMEOUT
710
+ except OSError as exc:
711
+ result["reason_code"] = UAT_PROBE_FAILED
712
+ result["error"] = str(exc)
713
+ return result
714
+
715
+
716
+ def resolve_and_probe(step_text: str, repo: Path) -> dict[str, object]:
717
+ kind, pre_reason = classify_step(step_text, Path(repo))
718
+ if kind is None:
719
+ return {
720
+ "probe_kind": None,
721
+ "reason_code": pre_reason or UAT_PROBE_UNRESOLVED,
722
+ "passed": False,
723
+ }
724
+ if pre_reason == UAT_PROBE_FORBIDDEN:
725
+ return {"probe_kind": kind, "reason_code": UAT_PROBE_FORBIDDEN, "passed": False}
726
+ if pre_reason == UAT_PROBE_UNRESOLVED and kind in ("manual_operator",):
727
+ return {
728
+ "probe_kind": kind,
729
+ "reason_code": UAT_PROBE_UNRESOLVED,
730
+ "passed": False,
731
+ }
732
+ return execute_probe(kind, step_text, Path(repo))
733
+
734
+
735
+ def probe_steps(steps: list[str], repo: Path) -> list[dict[str, object]]:
736
+ return [resolve_and_probe(step, repo) for step in steps]
737
+
738
+
739
+ def self_test() -> None:
740
+ repo = Path(__file__).resolve().parents[1]
741
+ assert "build" in PROBE_KINDS
742
+
743
+ r = classify_step("run unit tests", repo)
744
+ assert r[0] == "test" or r[1] in (UAT_PROBE_UNRESOLVED, "")
745
+
746
+ r2 = classify_step("read secrets from .env file", repo)
747
+ assert r2[1] == UAT_PROBE_FORBIDDEN
748
+
749
+ r3 = classify_step("enter password in login form", repo)
750
+ assert r3[1] == UAT_PROBE_FORBIDDEN
751
+
752
+ r4 = classify_step("operator visually confirms button click", repo)
753
+ assert r4[0] == "manual_operator"
754
+ assert r4[1] == UAT_PROBE_UNRESOLVED
755
+
756
+ r5 = classify_step("click submit button on home page", repo)
757
+ assert r5[0] == "browser_smoke" or r5[1] == UAT_PROBE_UNRESOLVED
758
+
759
+ r6 = classify_step("operator verifies compliance manually", repo)
760
+ assert r6[0] == "manual_operator"
761
+
762
+ mode = read_browser_probe_mode(repo)
763
+ assert mode in BROWSER_PROBE_MODES
764
+
765
+ bs = execute_browser_smoke("browser smoke test ui", repo)
766
+ assert bs.get("probe_mode") == "cursor"
767
+ assert bs["passed"] is False
768
+ if bs.get("target_url"):
769
+ assert bs.get("execution_tier") == "agent"
770
+ assert bs["reason_code"] == UAT_PROBE_UNRESOLVED
771
+ else:
772
+ assert bs["reason_code"] == UAT_PROBE_UNRESOLVED
773
+
774
+ bad_pass = validate_browser_evidence(
775
+ {"passed": True, "reason_code": UAT_PROBE_PASS},
776
+ "cursor",
777
+ )
778
+ assert bad_pass["passed"] is False
779
+ assert bad_pass["reason_code"] == UAT_BROWSER_PROBE_FAILED
780
+
781
+ good_pass = validate_browser_evidence(
782
+ {
783
+ "passed": True,
784
+ "reason_code": UAT_PROBE_PASS,
785
+ "browser_evidence_refs": {
786
+ "navigation_url": "http://localhost:3000/",
787
+ "screenshots": ["sprints/S0082/evidence/browser/pr-01.png"],
788
+ },
789
+ },
790
+ "cursor",
791
+ )
792
+ assert good_pass["passed"] is True
793
+
794
+ cli = execute_cli_smoke('run CLI `python -c "import sys; sys.exit(0)"` exit code 0', repo)
795
+ assert cli["passed"] is True
796
+ assert cli["reason_code"] == UAT_PROBE_PASS
797
+
798
+ ph = execute_process_health(
799
+ "wait for server startup readiness",
800
+ repo,
801
+ poll_cap=1,
802
+ poll_interval=1,
803
+ )
804
+ assert ph["reason_code"] in (UAT_PROBE_UNRESOLVED, UAT_PROBE_TIMEOUT, UAT_PROBE_FAILED)
805
+
806
+ merged = merge_result_fragment(
807
+ {"passed": True, "probe_kind": "browser_smoke"},
808
+ repo,
809
+ )
810
+ assert merged["passed"] is False
811
+
812
+ assert detect_stack_profile(repo) in ("python", "node", None, "generated")
813
+
814
+
815
+ def main() -> int:
816
+ import argparse
817
+
818
+ parser = argparse.ArgumentParser(description="UAT probe resolver (US-0092 / US-0093).")
819
+ parser.add_argument("--repo", default=".")
820
+ parser.add_argument("--step", action="append", default=[], help="Acceptance step text.")
821
+ parser.add_argument("--self-test", action="store_true")
822
+ parser.add_argument("--report", action="store_true", help="JSON probe results to stdout.")
823
+ parser.add_argument(
824
+ "--merge-result",
825
+ metavar="FRAGMENT.json",
826
+ help="Validate browser evidence fragment JSON (US-0093).",
827
+ )
828
+ args = parser.parse_args()
829
+ repo = Path(args.repo).resolve()
830
+
831
+ if args.self_test:
832
+ try:
833
+ self_test()
834
+ except AssertionError as exc:
835
+ print(f"self-test failed: {exc}", file=sys.stderr)
836
+ return 2
837
+ print("[UAT_PROBE_LIB_SELF_TEST_OK]")
838
+ return 0
839
+
840
+ if args.merge_result:
841
+ frag_path = Path(args.merge_result)
842
+ if not frag_path.is_file():
843
+ print(f"merge-result: file not found: {frag_path}", file=sys.stderr)
844
+ return 2
845
+ try:
846
+ fragment = json.loads(frag_path.read_text(encoding="utf-8"))
847
+ except json.JSONDecodeError as exc:
848
+ print(f"merge-result: invalid JSON: {exc}", file=sys.stderr)
849
+ return 2
850
+ validated = merge_result_fragment(fragment, repo)
851
+ print(json.dumps(validated, sort_keys=True, separators=(",", ":")))
852
+ return 0 if validated.get("passed") else 1
853
+
854
+ if args.report or args.step:
855
+ results = probe_steps(args.step or ["run tests"], repo)
856
+ print(json.dumps(results, sort_keys=True, separators=(",", ":")))
857
+ if any(r.get("reason_code") == UAT_PROBE_UNRESOLVED for r in results):
858
+ return 1
859
+ if any(not r.get("passed") for r in results):
860
+ return 1
861
+ return 0
862
+
863
+ parser.print_help()
864
+ return 2
865
+
866
+
867
+ if __name__ == "__main__":
868
+ sys.exit(main())