program-context-protocol 0.12.4__py3-none-any.whl

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 (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/telemetry.py ADDED
@@ -0,0 +1,308 @@
1
+ """Per-build-cycle telemetry — file/line/language/qa-result granularity for analysis.
2
+
3
+ Distinct from token_ledger.yaml (flat call-level cost rollup feeding pcp.md).
4
+ This is JSONL — one record per build-cycle event (a coding attempt, or a QA
5
+ check against that attempt) — so it loads straight into pandas/duckdb/jq for
6
+ analysis without parsing nested YAML. Auto-appended by `pcp build`. Never edit.
7
+ """
8
+
9
+ import json
10
+ from collections import defaultdict
11
+ from datetime import date, datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from pcp.evidence_chain import chain_entry
15
+
16
+ LANGUAGE_BY_EXT = {
17
+ ".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript",
18
+ ".jsx": "JavaScript", ".go": "Go", ".rs": "Rust", ".java": "Java", ".rb": "Ruby",
19
+ ".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".md": "Markdown",
20
+ ".sql": "SQL", ".sh": "Shell", ".css": "CSS", ".html": "HTML", ".c": "C",
21
+ ".cpp": "C++", ".h": "C/C++ header", ".swift": "Swift", ".kt": "Kotlin",
22
+ }
23
+
24
+
25
+ def infer_languages(file_paths: list[str]) -> list[str]:
26
+ langs = set()
27
+ for f in file_paths:
28
+ ext = Path(f).suffix
29
+ langs.add(LANGUAGE_BY_EXT.get(ext, ext.lstrip(".") or "unknown"))
30
+ return sorted(langs)
31
+
32
+
33
+ def count_diff_lines(diff: str) -> tuple[int, int]:
34
+ """(lines_added, lines_removed) from a unified diff, excluding +++/--- headers."""
35
+ added = sum(1 for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++"))
36
+ removed = sum(1 for l in diff.splitlines() if l.startswith("-") and not l.startswith("---"))
37
+ return added, removed
38
+
39
+
40
+ def record(pcp_dir: Path, **fields) -> None:
41
+ """Append one JSONL record to .pcp/telemetry.jsonl.
42
+
43
+ Suggested fields (not enforced — callers pass whatever's available):
44
+ module, submodule, criterion_id, cycle ("build"|"qa"), cycle_number (attempt #),
45
+ check (for qa: "layer1"|"architect-review"|"gate"), result ("pass"|"block"|"error"),
46
+ errors (list of finding strings), files (list of paths touched), languages,
47
+ lines_added, lines_removed, model, session_id, token_input, token_output,
48
+ token_cache_read, token_cache_creation, cost_usd, duration_ms.
49
+ """
50
+ fields = {"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), **fields}
51
+ path = Path(pcp_dir) / "telemetry.jsonl"
52
+ entry = chain_entry(_last_entry_hash(path), fields)
53
+ with open(path, "a") as f:
54
+ f.write(json.dumps(entry) + "\n")
55
+
56
+
57
+ def _last_entry_hash(path: Path) -> str | None:
58
+ if not path.exists():
59
+ return None
60
+ last_line = None
61
+ for line in path.read_text().splitlines():
62
+ line = line.strip()
63
+ if line:
64
+ last_line = line
65
+ if not last_line:
66
+ return None
67
+ try:
68
+ return json.loads(last_line).get("entry_hash")
69
+ except json.JSONDecodeError:
70
+ return None
71
+
72
+
73
+ def load(pcp_dir: Path) -> list[dict]:
74
+ path = Path(pcp_dir) / "telemetry.jsonl"
75
+ if not path.exists():
76
+ return []
77
+ records = []
78
+ for line in path.read_text().splitlines():
79
+ line = line.strip()
80
+ if not line:
81
+ continue
82
+ try:
83
+ records.append(json.loads(line))
84
+ except json.JSONDecodeError:
85
+ continue
86
+ return records
87
+
88
+
89
+ def productivity_by_week(records: list[dict]) -> list[dict]:
90
+ """Spend and net lines written per ISO week, plus $ per net line.
91
+
92
+ Nothing in PCP reported output-per-dollar over time, so a real 6x degradation
93
+ went unseen. Measured by hand on Project O 2026-07-30: 07-16..07-22
94
+ produced +4,954 net non-test LOC for $364.77 (~$0.07/line), while 07-23..07-30
95
+ produced +596 for $263.89 (~$0.44/line) -- while commits/day ROSE. Commit count
96
+ and output per dollar were pointing in opposite directions and only the flattering
97
+ one was visible anywhere.
98
+
99
+ Deliberately built from telemetry's own `lines_added`/`lines_removed`, not from
100
+ git, so this stays a pure aggregation over data PCP already owns and needs no
101
+ repo access. The honest reading of `net_lines` is therefore "lines this build
102
+ loop wrote, net" -- it counts every attempt's diff, including work a later
103
+ attempt superseded, so it is an upper bound on repo growth, not a measurement
104
+ of it. `$/net line` is a trend signal, not an accounting figure: watch whether
105
+ it moves, not what it equals.
106
+
107
+ Weeks with no recorded lines report None rather than a division-by-zero-shaped
108
+ number -- a week that spent money and wrote nothing is a real state and must not
109
+ be rendered as "$0.00/line".
110
+ """
111
+ by_week: dict[str, dict] = defaultdict(
112
+ lambda: {"cost": 0.0, "lines_added": 0, "lines_removed": 0, "attempts": 0}
113
+ )
114
+ for r in records:
115
+ ts = str(r.get("timestamp") or "")
116
+ if len(ts) < 10:
117
+ continue
118
+ try:
119
+ year, week, _ = date.fromisoformat(ts[:10]).isocalendar()
120
+ except ValueError:
121
+ continue
122
+ w = by_week[f"{year}-W{week:02d}"]
123
+ w["cost"] += r.get("cost_usd") or 0
124
+ w["lines_added"] += r.get("lines_added") or 0
125
+ w["lines_removed"] += r.get("lines_removed") or 0
126
+ if r.get("cycle") == "build":
127
+ w["attempts"] += 1
128
+
129
+ out = []
130
+ for label in sorted(by_week):
131
+ w = by_week[label]
132
+ net = w["lines_added"] - w["lines_removed"]
133
+ out.append({
134
+ "week": label,
135
+ "cost_usd": round(w["cost"], 2),
136
+ "net_lines": net,
137
+ "attempts": w["attempts"],
138
+ "usd_per_net_line": round(w["cost"] / net, 3) if net > 0 else None,
139
+ })
140
+ return out
141
+
142
+
143
+ _EMPTY_REPO_LINES = {"by_week": {}, "bulk_commits_skipped": {}, "bulk_threshold": 0}
144
+
145
+
146
+ def repo_net_lines_by_week(project_root: Path, exclude_tests: bool = True,
147
+ bulk_commit_threshold: int = 5000) -> dict:
148
+ """Net authored source lines that actually LANDED in the repo, per ISO week.
149
+
150
+ Returns {"by_week": {week: net}, "bulk_commits_skipped": {week: n},
151
+ "bulk_threshold": n}. Callers must surface `bulk_commits_skipped` -- a metric
152
+ that quietly drops data is the failure mode this whole module is trying to fix.
153
+
154
+ This exists because `productivity_by_week` alone is misleading, and shipping it
155
+ alone would have repeated the exact failure this session kept finding: a metric
156
+ that reads healthy while the situation it describes is not.
157
+
158
+ On Project O, week 2026-W31: telemetry recorded **+12,342 net lines
159
+ written** at $0.018/line, which looks like the most productive week of the run.
160
+ Git says non-test code in the repo grew by **+599** over the same window. Both
161
+ are true. Telemetry counts every attempt's diff, so superseded attempts, reverted
162
+ work, rewrites and test code all inflate it; git counts what survived.
163
+
164
+ Neither number is the interesting one. **The ratio is** — roughly 5% of what the
165
+ loop wrote survived as net non-test product code. That is the number worth
166
+ watching, and no single-source metric can express it.
167
+
168
+ **Vendored third-party source is the hard case and the reason for
169
+ `bulk_commit_threshold`.** Extension filtering is not enough: Project O
170
+ committed an entire drawio distribution under `web/drawio-site/` and
171
+ `web/public/drawio/` in one week and moved it the next -- ~450,000 lines of
172
+ third-party `.js` sitting in no conventionally-named vendor directory. That
173
+ produced survival rates of 107372% and -11249% before this filter existed.
174
+
175
+ No path heuristic catches that reliably, so the discriminator is size: a single
176
+ commit churning more than `bulk_commit_threshold` authored-source lines is a
177
+ vendor import, a bulk move, or a generated dump -- not one criterion's work.
178
+ Such commits are excluded WHOLE, and the count is returned per week so the
179
+ caller can say so out loud. Never silently.
180
+
181
+ Best-effort: returns empty structures when git is unavailable or the call fails,
182
+ so callers degrade to the telemetry-only view rather than breaking.
183
+ """
184
+ import subprocess
185
+
186
+ try:
187
+ proc = subprocess.run(
188
+ ["git", "log", "--numstat", "--date=short", "--pretty=format:__C__%cd"],
189
+ cwd=project_root, capture_output=True, text=True, timeout=120,
190
+ )
191
+ except (OSError, subprocess.SubprocessError):
192
+ return _EMPTY_REPO_LINES
193
+ if proc.returncode != 0:
194
+ return _EMPTY_REPO_LINES
195
+
196
+ # Per-commit first, so a bulk vendor import can be excluded as a whole.
197
+ per_commit: list[tuple[str, int, int]] = [] # (week, net, churn)
198
+ current: str | None = None
199
+ net = churn = 0
200
+
201
+ def flush():
202
+ if current is not None:
203
+ per_commit.append((current, net, churn))
204
+
205
+ for line in proc.stdout.splitlines():
206
+ if line.startswith("__C__"):
207
+ flush()
208
+ net = churn = 0
209
+ try:
210
+ year, week, _ = date.fromisoformat(line[5:].strip()).isocalendar()
211
+ current = f"{year}-W{week:02d}"
212
+ except ValueError:
213
+ current = None
214
+ continue
215
+ if not current or "\t" not in line:
216
+ continue
217
+ parts = line.split("\t")
218
+ # Binary files report "-" for both counts; skip rather than crash.
219
+ if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
220
+ continue
221
+ path = parts[2]
222
+ if not _is_authored_source(path):
223
+ continue
224
+ if exclude_tests and (path.startswith("tests/") or "/test_" in path
225
+ or path.split("/")[-1].startswith("test_")):
226
+ continue
227
+ added, removed = int(parts[0]), int(parts[1])
228
+ net += added - removed
229
+ churn += added + removed
230
+ flush()
231
+
232
+ by_week: dict[str, int] = defaultdict(int)
233
+ skipped: dict[str, int] = defaultdict(int)
234
+ for week, cnet, cchurn in per_commit:
235
+ if cchurn > bulk_commit_threshold:
236
+ skipped[week] += 1
237
+ continue
238
+ by_week[week] += cnet
239
+ return {"by_week": dict(by_week), "bulk_commits_skipped": dict(skipped),
240
+ "bulk_threshold": bulk_commit_threshold}
241
+
242
+
243
+ # Extensions a human or an agent actually authors. Everything else -- lockfiles,
244
+ # bundles, vendored trees, snapshots, data -- is generated or copied, and counting
245
+ # it destroys the metric rather than enriching it.
246
+ _SOURCE_EXTS = frozenset({
247
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb", ".kt",
248
+ ".swift", ".c", ".cpp", ".h", ".cs", ".php", ".scala", ".ex", ".exs",
249
+ ".sh", ".sql", ".vue", ".svelte", ".css", ".scss", ".html",
250
+ })
251
+ _GENERATED_DIR_MARKERS = (
252
+ "node_modules/", ".venv/", "venv/", "vendor/", "dist/", "build/", "site-packages/",
253
+ "__pycache__/", ".next/", "coverage/", "migrations/", "__snapshots__/", "generated/",
254
+ )
255
+ _GENERATED_NAMES = frozenset({
256
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock",
257
+ "Cargo.lock", "go.sum", "uv.lock", "composer.lock",
258
+ })
259
+
260
+
261
+ def _is_authored_source(path: str) -> bool:
262
+ """Is this a file a person or agent wrote, rather than one a tool emitted?
263
+
264
+ Without this filter the metric is not merely noisy, it is nonsense. Run against
265
+ Project O it reported **+1,523,5xx lines landed** in one week and
266
+ **-1,470,6xx** in another, for survival rates of 362745% and -38701% -- lockfiles
267
+ and generated bundles swamping the signal by three orders of magnitude.
268
+
269
+ Worth stating plainly: that output was produced by a first version of this
270
+ function and caught only by running it against a real repo before shipping.
271
+ A metric that confidently reports a wrong number is worse than no metric, which
272
+ is the same defect class as every other finding in this session's audit.
273
+ """
274
+ name = path.split("/")[-1]
275
+ if name in _GENERATED_NAMES:
276
+ return False
277
+ if any(marker in path for marker in _GENERATED_DIR_MARKERS):
278
+ return False
279
+ dot = name.rfind(".")
280
+ return dot > 0 and name[dot:].lower() in _SOURCE_EXTS
281
+
282
+
283
+ def aggregate(records: list[dict]) -> dict:
284
+ """Roll up build/qa records per module. Shared by `pcp telemetry`, end-of-build
285
+ summary, and the pcp.md 'Build Efficiency' section — one aggregation, three views."""
286
+ build_records = [r for r in records if r.get("cycle") == "build"]
287
+ qa_records = [r for r in records if r.get("cycle") == "qa"]
288
+
289
+ by_module = defaultdict(lambda: {
290
+ "attempts": 0, "criteria": set(), "tokens_in": 0, "tokens_out": 0,
291
+ "tokens_cache_read": 0, "cost": 0.0, "qa_blocks": 0, "qa_total": 0, "languages": set(),
292
+ })
293
+ for r in build_records:
294
+ m = by_module[r.get("module") or "?"]
295
+ m["attempts"] += 1
296
+ m["criteria"].add(r.get("criterion_id"))
297
+ m["tokens_in"] += r.get("token_input", 0)
298
+ m["tokens_out"] += r.get("token_output", 0)
299
+ m["tokens_cache_read"] += r.get("token_cache_read", 0)
300
+ m["cost"] += r.get("cost_usd") or 0
301
+ m["languages"].update(r.get("languages") or [])
302
+ for r in qa_records:
303
+ m = by_module[r.get("module") or "?"]
304
+ m["qa_total"] += 1
305
+ if r.get("result") == "block":
306
+ m["qa_blocks"] += 1
307
+
308
+ return {"by_module": by_module, "build_records": build_records, "qa_records": qa_records}
pcp/uat.py ADDED
@@ -0,0 +1,271 @@
1
+ """UAT checks — `url_responds`, `dom_contains`, and `visual` acceptance criteria,
2
+ plus two advisory checks build.py runs on top of a rendered screenshot:
3
+ `check_axe` (deterministic a11y scan) and `check_visual_quality` (checklist-
4
+ anchored VLM judge).
5
+
6
+ Honest scope: `url_responds`/`dom_contains` are deterministic, no browser
7
+ involved — `dom_contains` fetches the raw HTML response and searches it as
8
+ text, so it does NOT execute JavaScript and content only rendered
9
+ client-side (a typical SPA) won't be found even if a real browser would
10
+ show it. `visual` (check_visual) closes part of that gap with a real
11
+ headless browser via Playwright — an OPTIONAL dependency
12
+ (`pip install program-context-protocol[visual]`), never a hard requirement
13
+ of this package. It proves the page renders without crashing/timing out
14
+ and saves a screenshot for human review.
15
+
16
+ **Updated 2026-07-18** — layout-break detection via a vision LLM is now
17
+ built (`check_visual_quality`), closing the gap this docstring used to name
18
+ as out of scope. What changed: `llm/client.py` gained image-input plumbing
19
+ (`call_with_image`/`call_json_with_image`, via `claude -p`'s
20
+ `--input-format stream-json` multimodal message shape). Deliberately
21
+ **checklist-anchored, not a freeform "does this look good" prompt** —
22
+ research (ArtifactsBench, 2026) found a checklist-anchored VLM judge hits
23
+ ~94% human-correlation vs. ~21% for a bare Nielsen-heuristics-style review;
24
+ the checklist is what does the work, not the model. Same advisory,
25
+ never-a-hard-block posture as `check_visual`'s baseline-diff note below —
26
+ a screen scoring poorly on the checklist is a review signal, not proof the
27
+ screen is wrong.
28
+
29
+ Reconnaissance-then-action pattern (wait for `networkidle` before reading
30
+ DOM state) is a reference-pattern borrowed from Anthropic's own
31
+ `webapp-testing` skill (anthropics/skills) after a real prior-art miss
32
+ found post-hoc: `check_visual` shipped without checking for it first,
33
+ initially screenshotting right after `goto()` with no settle-wait — on a
34
+ slow-loading SPA that can capture a blank/loading state and still report
35
+ "rendered successfully," exactly the failure mode this check exists to
36
+ catch. `webapp-testing` itself is a full agentic skill (server lifecycle,
37
+ selector discovery, browser console logs) meant for an interactive Claude
38
+ session — not adopted wholesale, since `check_visual` runs inside `pcp
39
+ scan`'s deterministic, zero-LLM evaluation loop and turning that into an
40
+ agent invocation per UI criterion would break Token Discipline. Only the
41
+ underlying technique was reused; `pcp build`'s coding agent is pointed at
42
+ the real skill separately for its own in-build UI verification.
43
+
44
+ Same tool-wrapping shape as qa.py: never raises, degrades to a clear
45
+ failure detail instead.
46
+ """
47
+
48
+ import re
49
+ import shutil
50
+ import subprocess
51
+ import urllib.error
52
+ import urllib.request
53
+ from pathlib import Path
54
+
55
+ TIMEOUT_SEC = 10
56
+ TIMEOUT_AXE = 120
57
+
58
+ # Checklist-anchored, per the research finding above -- deliberately generic
59
+ # and small rather than exhaustive, same "advisory signal, not proof" posture
60
+ # as check_visual's baseline diff. A criterion's own design_justification
61
+ # (design_system.md tokens, jtbd_framing) is NOT folded in here as additional
62
+ # checklist items -- that field is a self-report the agent fills in, judging
63
+ # a screen against the agent's own claims about itself would be circular.
64
+ DEFAULT_VISUAL_CHECKLIST = [
65
+ "layout is not visibly broken (no overlapping elements, no obvious clipping/overflow)",
66
+ "text is legible (adequate contrast against its background, not truncated where it shouldn't be)",
67
+ "primary action or focal element is visually prominent and easy to locate",
68
+ "spacing/alignment reads as intentional, not haphazard",
69
+ ]
70
+
71
+
72
+ def check_url_responds(url: str) -> tuple[bool, str]:
73
+ """Pass if the URL returns a 2xx/3xx status. No content is inspected."""
74
+ if not url:
75
+ return False, "no url configured for url_responds check"
76
+ try:
77
+ req = urllib.request.Request(url, method="GET")
78
+ with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
79
+ ok = 200 <= resp.status < 400
80
+ return ok, f"{url} responded {resp.status}"
81
+ except urllib.error.HTTPError as e:
82
+ return False, f"{url} responded {e.code}"
83
+ except Exception as e:
84
+ return False, f"{url} did not respond: {e}"
85
+
86
+
87
+ def check_dom_contains(url: str, selector: str) -> tuple[bool, str]:
88
+ """Pass if `selector` (plain text, or a regex if it fails to match literally)
89
+ appears in the URL's raw HTML response. Static content only — see module
90
+ docstring for the JS-rendering limitation."""
91
+ if not url:
92
+ return False, "no url configured for dom_contains check"
93
+ if not selector:
94
+ return False, "no selector/text configured for dom_contains check"
95
+ try:
96
+ req = urllib.request.Request(url, method="GET")
97
+ with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
98
+ body = resp.read().decode(errors="replace")
99
+ except Exception as e:
100
+ return False, f"{url} did not respond: {e}"
101
+
102
+ if selector in body:
103
+ return True, f"'{selector}' found in {url} (static HTML)"
104
+ try:
105
+ if re.search(selector, body):
106
+ return True, f"pattern '{selector}' matched in {url} (static HTML)"
107
+ except re.error:
108
+ pass
109
+ return False, f"'{selector}' not found in {url}'s static HTML (JS-rendered content won't show here)"
110
+
111
+
112
+ def check_visual(url: str, screenshot_path: Path | None = None) -> tuple[bool | None, str]:
113
+ """Loads `url` in a real headless browser (Playwright/Chromium) and
114
+ captures a screenshot. Returns (None, detail) — not (False, detail) —
115
+ when playwright isn't installed: this means "could not check", not
116
+ "failed". Callers must preserve whatever status a criterion already had
117
+ rather than downgrading it on a missing optional dependency, the same
118
+ posture a manual/visual criterion without this check already gets in
119
+ scan.py."""
120
+ if not url:
121
+ return False, "no url configured for visual check"
122
+ try:
123
+ from playwright.sync_api import sync_playwright
124
+ except ImportError:
125
+ return None, (
126
+ "playwright not installed -- visual check skipped, not failed. "
127
+ "Install with: pip install program-context-protocol[visual] && playwright install chromium"
128
+ )
129
+ try:
130
+ with sync_playwright() as p:
131
+ browser = p.chromium.launch()
132
+ page = browser.new_page()
133
+ page.goto(url, timeout=TIMEOUT_SEC * 1000)
134
+ # Reconnaissance-then-action pattern (reference: anthropics/skills'
135
+ # webapp-testing) -- goto() alone returns once the initial HTML
136
+ # response lands, before a typical SPA's JS has actually rendered
137
+ # content. Without this wait, a screenshot on a slow-loading SPA
138
+ # can capture a blank/loading state and still report "rendered
139
+ # successfully" -- exactly the failure mode this check exists to
140
+ # catch (dom_contains's own JS-rendering gap). networkidle waits
141
+ # for in-flight requests to settle before the check reads DOM state.
142
+ page.wait_for_load_state("networkidle", timeout=TIMEOUT_SEC * 1000)
143
+ if screenshot_path:
144
+ screenshot_path.parent.mkdir(parents=True, exist_ok=True)
145
+ page.screenshot(path=str(screenshot_path), full_page=True)
146
+ browser.close()
147
+ except Exception as e:
148
+ return False, f"{url} failed to render in a headless browser: {e}"
149
+
150
+ detail = f"{url} rendered successfully in a headless browser"
151
+ if screenshot_path:
152
+ detail += f" -- screenshot: {screenshot_path}"
153
+ detail += _baseline_note(screenshot_path)
154
+ return True, detail
155
+
156
+
157
+ def _baseline_note(screenshot_path: Path) -> str:
158
+ """Baseline comparison (Chromatic reference pattern, 2026-07-17, build
159
+ plan 3.5) — closes part of check_visual's own stated 'not visual
160
+ regression testing' gap. First successful capture becomes the baseline
161
+ (`<name>_baseline.png`); later captures are compared by content hash.
162
+ HONEST SCOPE: hash inequality means "pixels changed since the accepted
163
+ baseline", not "layout broke" — a changed screenshot is a review signal,
164
+ never a failure. Accept a new baseline by deleting the old one."""
165
+ import hashlib
166
+ baseline = screenshot_path.with_name(screenshot_path.stem + "_baseline.png")
167
+ try:
168
+ current = hashlib.sha256(screenshot_path.read_bytes()).hexdigest()
169
+ if not baseline.exists():
170
+ baseline.write_bytes(screenshot_path.read_bytes())
171
+ return " -- baseline established (first capture)"
172
+ if hashlib.sha256(baseline.read_bytes()).hexdigest() == current:
173
+ return " -- matches accepted baseline"
174
+ return (f" -- CHANGED vs accepted baseline ({baseline.name}); review the two "
175
+ "screenshots and delete the baseline to accept the new look")
176
+ except OSError as e:
177
+ return f" -- baseline comparison skipped: {e}"
178
+
179
+
180
+ def check_axe(url: str) -> tuple[bool | None, str]:
181
+ """Deterministic WCAG a11y scan via `@axe-core/cli` (npx, auto-installed
182
+ on first run same as the Context7 MCP entry doctor.py already scaffolds
183
+ via npx -- no new hard dependency added to this package). Returns
184
+ (None, detail) when npx isn't on PATH -- "could not check", same
185
+ could-not-check-vs-failed distinction check_visual already makes for a
186
+ missing optional dependency. --exit makes the CLI process exit 1 if any
187
+ rule fails; --stdout silences everything but the results/errors so the
188
+ tail of stdout is a usable detail message on failure."""
189
+ if not url:
190
+ return False, "no url configured for axe a11y check"
191
+ if not shutil.which("npx"):
192
+ return None, "npx not found -- axe-core a11y scan skipped, not failed"
193
+ try:
194
+ result = subprocess.run(
195
+ ["npx", "--yes", "@axe-core/cli", url, "--exit", "--stdout"],
196
+ capture_output=True, text=True, timeout=TIMEOUT_AXE,
197
+ )
198
+ except subprocess.TimeoutExpired:
199
+ return False, f"axe-core scan of {url} timed out after {TIMEOUT_AXE}s"
200
+ except Exception as e:
201
+ return False, f"axe-core scan of {url} failed to run: {e}"
202
+
203
+ output = (result.stdout + result.stderr).strip()
204
+ if result.returncode == 0:
205
+ return True, f"axe-core: no violations found at {url}"
206
+ return False, f"axe-core found violation(s) at {url}:\n{output[-3000:]}"
207
+
208
+
209
+ def check_visual_quality(
210
+ screenshot_path: Path,
211
+ checklist: list[str] | None = None,
212
+ reference_image_path: Path | None = None,
213
+ model: str | None = None,
214
+ pcp_dir: Path | None = None,
215
+ ) -> tuple[bool | None, str, list[dict]]:
216
+ """Checklist-anchored VLM judge over a screenshot check_visual already
217
+ captured. Returns (None, detail, []) if the screenshot doesn't exist
218
+ (nothing to judge -- same could-not-check posture as a missing optional
219
+ dependency elsewhere in this module) or if the judge call itself errors
220
+ (advisory check, never let an LLM-call failure read as a verdict).
221
+
222
+ reference_image_path, if given, is attached as a second inline image so
223
+ the judge can compare against it -- research finding: screenshot +
224
+ reference beats either alone as grounding context. Comparison is
225
+ layout/structure, not pixel-perfect match; the prompt says so explicitly
226
+ so the judge doesn't fail a screen for a legitimate content difference.
227
+ """
228
+ if not screenshot_path or not screenshot_path.exists():
229
+ return None, "no screenshot available to judge (check_visual must run first)", []
230
+
231
+ from pcp.llm import client as llm
232
+
233
+ items = checklist or DEFAULT_VISUAL_CHECKLIST
234
+ checklist_text = "\n".join(f"- {item}" for item in items)
235
+ system = (
236
+ "You judge a rendered UI screenshot against a fixed checklist. For EACH "
237
+ "checklist item, decide pass/fail and give a one-sentence reason grounded "
238
+ "in what you actually see in the image -- never invent a defect that isn't "
239
+ "visible. If a reference image is also attached, use it only to judge "
240
+ "layout/structure similarity, not pixel-perfect match; a legitimate content "
241
+ "difference (different copy, different data) is not a failure."
242
+ )
243
+ user = (
244
+ f"Checklist:\n{checklist_text}\n\n"
245
+ "Respond with JSON: "
246
+ '{"items": [{"item": "<checklist item text>", "passed": true|false, "reason": "..."}], '
247
+ '"overall_passed": true|false}. overall_passed is true only if every item passed.'
248
+ )
249
+ if reference_image_path and reference_image_path.exists():
250
+ user += "\n\nA reference image is attached second, after the rendered screenshot, for comparison."
251
+
252
+ image_paths = [screenshot_path]
253
+ if reference_image_path and reference_image_path.exists():
254
+ image_paths.append(reference_image_path)
255
+
256
+ try:
257
+ verdict = llm.call_json_with_images(
258
+ system, user, image_paths,
259
+ model=model or llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="uat.check_visual_quality",
260
+ )
261
+ except Exception as e:
262
+ return None, f"visual-quality judge call failed: {e}", []
263
+
264
+ checked_items = verdict.get("items", [])
265
+ overall = bool(verdict.get("overall_passed", all(i.get("passed") for i in checked_items)))
266
+ failed = [i for i in checked_items if not i.get("passed")]
267
+ if not overall and failed:
268
+ detail = "; ".join(f"{i.get('item', '?')}: {i.get('reason', '')}" for i in failed)
269
+ else:
270
+ detail = "all checklist items passed"
271
+ return overall, detail, checked_items