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/commands/doctor.py ADDED
@@ -0,0 +1,820 @@
1
+ """pcp doctor — environment preflight. Checks which CLI integrations are
2
+ available, writes .pcp/integrations.yaml. Interactive mode asks for the ones
3
+ that need human-provided config (deploy command, health-check URL).
4
+
5
+ pcp build/watch/deploy call check_environment() at the start of their run in
6
+ non-interactive report-only mode — never blocks on missing optional tooling,
7
+ only on git/claude (required for the lifecycle to function at all).
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ import click
20
+ import yaml
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+
24
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
25
+
26
+ console = Console()
27
+
28
+ REQUIRED_TOOLS = ["git", "claude"]
29
+
30
+ DEFAULT_SCHEMA_BLOAT_THRESHOLD = 50
31
+ DEFAULT_TEST_SCHEMA_PATTERN = "test_%"
32
+ DEFAULT_ORPHAN_WORKTREE_AGE_HOURS = 24
33
+ DEFAULT_TIMEOUT_HEAVY_THRESHOLD = 2
34
+ _SAFE_SCHEMA_PATTERN_RE = re.compile(r"^[A-Za-z0-9_%-]+$")
35
+
36
+ DEPLOY_HINT_FILES = {
37
+ "railway.toml": "railway up",
38
+ "vercel.json": "vercel --prod",
39
+ "Procfile": "git push origin main",
40
+ "Dockerfile": None,
41
+ }
42
+
43
+ # Context7 (upstash/context7) injects live, version-specific library docs
44
+ # into a coding agent's context instead of relying on stale training data --
45
+ # a real mitigation for hallucinated/deprecated API usage, verified real via
46
+ # WebSearch 2026-07-17 (github.com/upstash/context7, official, free). Wired
47
+ # via the project's own .mcp.json, not a PCP-specific mechanism -- any
48
+ # `claude -p` session run with this project as cwd (including pcp build's
49
+ # coding-agent subprocess) picks it up automatically once configured.
50
+ CONTEXT7_MCP_ENTRY = {"command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"]}
51
+
52
+
53
+ def _which(name: str) -> str | None:
54
+ return shutil.which(name)
55
+
56
+
57
+ def _claude_bin_for_detection() -> str:
58
+ """Real bug, found 2026-07-18 (CI failure, Project O-adjacent):
59
+ llm/client.py's _claude_bin() respects PCP_CLAUDE_BIN so callers can
60
+ substitute a stub agent (real substitution in tests, or a genuinely
61
+ different install path), but this module's own REQUIRED_TOOLS check for
62
+ "claude" only ever looked for a literal `claude` on PATH -- two
63
+ different sources of truth for "is claude available" that silently
64
+ agreed on every dev machine (both true) and silently disagreed the
65
+ moment an environment has a working PCP_CLAUDE_BIN stub but no real
66
+ `claude` binary on PATH (exactly GitHub Actions' ubuntu-latest runners,
67
+ which is why build.py's fatal preflight check blocked CI even though
68
+ several tests deliberately point PCP_CLAUDE_BIN at a fake, working
69
+ agent script). shutil.which() already handles an absolute path
70
+ correctly (bypasses PATH, just checks that exact file is executable),
71
+ so this only needs to feed it the right name."""
72
+ return os.environ.get("PCP_CLAUDE_BIN") or "claude"
73
+
74
+
75
+ def detect_tools() -> dict:
76
+ """Pure detection, no prompts. Used by both `pcp doctor` and the
77
+ non-interactive preflight other commands run automatically."""
78
+ claude_bin = _claude_bin_for_detection()
79
+ return {
80
+ "git": {"available": _which("git") is not None, "path": _which("git")},
81
+ "claude": {"available": _which(claude_bin) is not None, "path": _which(claude_bin)},
82
+ "gh": {"available": _which("gh") is not None, "path": _which("gh")},
83
+ "test_runner": _detect_one(["pytest", "npm", "go"]),
84
+ "lint": _detect_one(["ruff", "eslint"]),
85
+ "sast": _detect_one(["semgrep"]),
86
+ "coverage": _detect_one(["coverage"]),
87
+ "audit": _detect_one(["vulture", "knip"]),
88
+ "ast_grep": {"available": _which("ast-grep") is not None, "path": _which("ast-grep")},
89
+ "jscpd": {"available": _which("jscpd") is not None, "path": _which("jscpd")},
90
+ "slack_notify": {"available": _which("slack-notify") is not None, "path": _which("slack-notify")},
91
+ "npx": {"available": _which("npx") is not None, "path": _which("npx")},
92
+ "opa": _detect_one(["opa"]),
93
+ "temporal": _detect_one(["temporal"]),
94
+ # Only needed if PCP_VERIFIER_CROSS_VENDOR=1 is set (Loop 3's
95
+ # cross-vendor architect-review verifier leg, build.py's
96
+ # _verify_block_findings) -- optional, same posture as gh/opa above.
97
+ "agy": {"available": _which("agy") is not None, "path": _which("agy")},
98
+ }
99
+
100
+
101
+ def _detect_one(candidates: list[str]) -> dict:
102
+ for c in candidates:
103
+ path = _which(c)
104
+ if path:
105
+ return {"tool": c, "available": True, "path": path}
106
+ return {"tool": None, "available": False, "path": None}
107
+
108
+
109
+ def _guess_deploy_command(project_root: Path) -> str | None:
110
+ for fname, cmd in DEPLOY_HINT_FILES.items():
111
+ if (project_root / fname).exists() and cmd:
112
+ return cmd
113
+ return None
114
+
115
+
116
+ def _mcp_config_path(project_root: Path) -> Path:
117
+ return project_root / ".mcp.json"
118
+
119
+
120
+ def detect_context7(project_root: Path) -> dict:
121
+ """Pure detection -- npx availability (needed to run the MCP server) and
122
+ whether .mcp.json already declares it. Project-scoped (needs project_root
123
+ for .mcp.json), so kept separate from detect_tools()'s pure PATH-lookup shape."""
124
+ config_path = _mcp_config_path(project_root)
125
+ configured = False
126
+ if config_path.exists():
127
+ try:
128
+ data = json.loads(config_path.read_text())
129
+ configured = "context7" in (data.get("mcpServers") or {})
130
+ except (json.JSONDecodeError, OSError):
131
+ pass
132
+ return {
133
+ "npx_available": _which("npx") is not None,
134
+ "configured": configured,
135
+ "config_path": str(config_path),
136
+ }
137
+
138
+
139
+ def configure_context7(project_root: Path) -> bool:
140
+ """Adds a context7 entry to .mcp.json, creating the file if absent and
141
+ preserving any other MCP servers already declared there. Returns False
142
+ (and touches nothing) if the file exists but isn't valid JSON -- never
143
+ silently clobber a config a human hand-wrote."""
144
+ config_path = _mcp_config_path(project_root)
145
+ data = {}
146
+ if config_path.exists():
147
+ try:
148
+ data = json.loads(config_path.read_text())
149
+ except json.JSONDecodeError:
150
+ console.print(
151
+ f"[yellow]⚠ {config_path.name} exists but isn't valid JSON -- "
152
+ "skipping Context7 setup, add it manually.[/yellow]"
153
+ )
154
+ return False
155
+ data.setdefault("mcpServers", {})
156
+ data["mcpServers"]["context7"] = dict(CONTEXT7_MCP_ENTRY)
157
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
158
+ return True
159
+
160
+
161
+ def _declared_tiers(pcp_dir: Path) -> set[int]:
162
+ tiers: set[int] = set()
163
+ modules_dir = pcp_dir / "strategy" / "modules"
164
+ if not modules_dir.exists():
165
+ return tiers
166
+ for acc in modules_dir.glob("*/acceptance.yaml"):
167
+ try:
168
+ data = yaml.safe_load(acc.read_text()) or {}
169
+ except yaml.YAMLError:
170
+ continue
171
+ for c in data.get("criteria", []):
172
+ tier = c.get("logic_tier")
173
+ if isinstance(tier, int):
174
+ tiers.add(tier)
175
+ return tiers
176
+
177
+
178
+ def _project_mentions(project_root: Path, names: tuple[str, ...]) -> bool:
179
+ """Cheap check: any of these package names in requirements/pyproject/package.json."""
180
+ for fname in ("requirements.txt", "pyproject.toml", "package.json", "requirements-dev.txt"):
181
+ p = project_root / fname
182
+ if p.exists():
183
+ text = p.read_text(errors="replace").lower()
184
+ if any(n in text for n in names):
185
+ return True
186
+ return False
187
+
188
+
189
+ def _rung_tooling_recommendations(pcp_dir: Path, project_root: Path) -> list[str]:
190
+ """Advisory lines for logic-tier declarations missing their standard
191
+ tooling. Deterministic, zero LLM."""
192
+ tiers = _declared_tiers(pcp_dir)
193
+ recs = []
194
+ if 6 in tiers and not _project_mentions(project_root, ("outlines", "instructor", "guardrails", "baml")):
195
+ recs.append(
196
+ "rung-6 (LLM) criteria declared but no structured-output library found "
197
+ "(outlines/instructor/guardrails/baml) — rung-6 outputs must be schema-validated; "
198
+ "Outlines (constrained decoding) or Instructor (typed retry) are the standard picks"
199
+ )
200
+ if 5 in tiers and not _project_mentions(project_root, ("gptcache", "redis", "litellm", "bifrost")):
201
+ recs.append(
202
+ "rung-5 (cached-reuse) criteria declared but no semantic-cache dependency found — "
203
+ "GPTCache successors: Bifrost (gateway-native), LiteLLM, RedisSemanticCache"
204
+ )
205
+ if 4 in tiers and not _project_mentions(project_root, ("semantic-router", "semantic_router", "chromadb", "faiss", "pinecone", "qdrant", "weaviate")):
206
+ recs.append(
207
+ "rung-4 (RAG) criteria declared but no retrieval dependency found — "
208
+ "semantic-router (pre-LLM intent routing) or a vector store (chroma/faiss/qdrant) expected"
209
+ )
210
+ return recs
211
+
212
+
213
+ def _postgres_url() -> str | None:
214
+ for var in ("DATABASE_URL", "POSTGRES_URL", "PG_DATABASE_URL", "TEST_DATABASE_URL"):
215
+ val = os.environ.get(var)
216
+ if val and "postgres" in val:
217
+ return val
218
+ return None
219
+
220
+
221
+ def check_schema_bloat(threshold: int | None = None, pattern: str | None = None) -> dict | None:
222
+ """Postgres test-schema bloat preflight (2026-07-24, Project O
223
+ incident root cause): a schema-per-test pattern that never tears down
224
+ left 2000+ stray schemas, which correlated directly with pytest timeouts
225
+ under worktree-parallel builds -- looked like a `pcp build` stall from
226
+ the outside, was actually Postgres. Returns None (inert, never blocks)
227
+ if no postgres connection is configured or `psql` isn't on PATH -- pure
228
+ advisory detection, same warn-first posture every other doctor check
229
+ uses. Pattern is restricted to LIKE-safe characters (no quote-breakout)
230
+ since it's interpolated into a query string."""
231
+ url = _postgres_url()
232
+ if not url or not _which("psql"):
233
+ return None
234
+ threshold = threshold if threshold is not None else int(
235
+ os.environ.get("PCP_SCHEMA_BLOAT_THRESHOLD", DEFAULT_SCHEMA_BLOAT_THRESHOLD)
236
+ )
237
+ pattern = pattern or os.environ.get("PCP_TEST_SCHEMA_PATTERN", DEFAULT_TEST_SCHEMA_PATTERN)
238
+ if not _SAFE_SCHEMA_PATTERN_RE.match(pattern):
239
+ return None
240
+ query = f"SELECT count(*) FROM information_schema.schemata WHERE schema_name LIKE '{pattern}'"
241
+ try:
242
+ result = subprocess.run(["psql", url, "-tAc", query], capture_output=True, text=True, timeout=15)
243
+ except (OSError, subprocess.TimeoutExpired):
244
+ return None
245
+ if result.returncode != 0:
246
+ return None
247
+ try:
248
+ count = int(result.stdout.strip())
249
+ except ValueError:
250
+ return None
251
+ return {"count": count, "pattern": pattern, "threshold": threshold, "bloated": count > threshold}
252
+
253
+
254
+ def fix_schema_bloat(pattern: str) -> dict:
255
+ """Drops every schema matching `pattern` -- destructive, the CLI command
256
+ owns confirmation (never called on a bare `pcp doctor`). Returns
257
+ {"dropped": [...], "errors": [...]}."""
258
+ if not _SAFE_SCHEMA_PATTERN_RE.match(pattern):
259
+ return {"dropped": [], "errors": [f"unsafe pattern: {pattern!r}"]}
260
+ url = _postgres_url()
261
+ if not url or not _which("psql"):
262
+ return {"dropped": [], "errors": ["no postgres connection or psql binary detected"]}
263
+ list_query = f"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE '{pattern}'"
264
+ result = subprocess.run(["psql", url, "-tAc", list_query], capture_output=True, text=True, timeout=30)
265
+ if result.returncode != 0:
266
+ return {"dropped": [], "errors": [result.stderr.strip()]}
267
+ schemas = [s.strip() for s in result.stdout.splitlines() if s.strip()]
268
+ dropped, errors = [], []
269
+ for s in schemas:
270
+ r = subprocess.run(
271
+ ["psql", url, "-c", f'DROP SCHEMA IF EXISTS "{s}" CASCADE'],
272
+ capture_output=True, text=True, timeout=30,
273
+ )
274
+ if r.returncode == 0:
275
+ dropped.append(s)
276
+ else:
277
+ errors.append(f"{s}: {r.stderr.strip()}")
278
+ return {"dropped": dropped, "errors": errors}
279
+
280
+
281
+ def _pcp_worktrees(project_root: Path) -> list[dict]:
282
+ """Git worktrees `pcp build` itself created -- branch `feat/<module>[-<criterion>]`,
283
+ sibling dir `<repo>-<module>[-<criterion>]`, exactly build.py's `_setup_worktree`/
284
+ `_worktree_dir` naming convention. Never matches a worktree a human created for
285
+ unrelated work."""
286
+ try:
287
+ proc = subprocess.run(
288
+ ["git", "worktree", "list", "--porcelain"],
289
+ cwd=project_root, capture_output=True, text=True, timeout=15,
290
+ )
291
+ except (OSError, subprocess.SubprocessError):
292
+ return []
293
+ if proc.returncode != 0:
294
+ return []
295
+ worktrees, current = [], {}
296
+ for line in proc.stdout.splitlines():
297
+ if line.startswith("worktree "):
298
+ if current:
299
+ worktrees.append(current)
300
+ current = {"path": line[len("worktree "):].strip()}
301
+ elif line.startswith("branch "):
302
+ current["branch"] = line[len("branch "):].strip().removeprefix("refs/heads/")
303
+ if current:
304
+ worktrees.append(current)
305
+ prefix = f"{project_root.name}-"
306
+ return [
307
+ w for w in worktrees
308
+ if w.get("branch", "").startswith("feat/")
309
+ and Path(w["path"]) != project_root
310
+ and Path(w["path"]).name.startswith(prefix)
311
+ ]
312
+
313
+
314
+ def check_orphaned_worktrees(project_root: Path) -> list[dict]:
315
+ """PCP-created git worktrees with no commit activity in
316
+ PCP_ORPHAN_WORKTREE_AGE_HOURS (default 24) -- orphaned by a crashed or
317
+ killed `pcp build` run.
318
+
319
+ build.py's `_cleanup_worktree` only runs on the graceful-success and
320
+ graceful-merge-failure paths (the latter deliberately leaves the worktree
321
+ for manual inspection -- see build.py's own comments). A genuine process
322
+ crash or kill mid-wave hits neither path: the wave's `worktrees = {...}`
323
+ dict is built with no try/finally around it, so nothing ever tracks those
324
+ dirs again. Age is measured from the worktree branch's last commit, not
325
+ filesystem mtime (stat noise), and stays well clear of a real in-progress
326
+ attempt: PCP_BUILD_AGENT_TIMEOUT_SEC defaults to 30 minutes across up to 3
327
+ attempts, so a genuinely active criterion is hours old at most, not a day.
328
+
329
+ Advisory detection only -- never deletes without `--fix-orphaned-worktrees`
330
+ + confirmation, same posture as schema-bloat cleanup below.
331
+ """
332
+ if not (project_root / ".git").exists():
333
+ return []
334
+ threshold_hours = int(os.environ.get("PCP_ORPHAN_WORKTREE_AGE_HOURS", str(DEFAULT_ORPHAN_WORKTREE_AGE_HOURS)))
335
+ now = datetime.now(timezone.utc)
336
+ orphans = []
337
+ for w in _pcp_worktrees(project_root):
338
+ path = Path(w["path"])
339
+ if not path.exists():
340
+ continue
341
+ log = subprocess.run(
342
+ ["git", "log", "-1", "--format=%ct", w["branch"]],
343
+ cwd=project_root, capture_output=True, text=True, timeout=15,
344
+ )
345
+ if log.returncode != 0:
346
+ continue
347
+ try:
348
+ last_commit_ts = datetime.fromtimestamp(int(log.stdout.strip()), tz=timezone.utc)
349
+ except (ValueError, OSError):
350
+ continue
351
+ age_hours = (now - last_commit_ts).total_seconds() / 3600
352
+ if age_hours >= threshold_hours:
353
+ orphans.append({"path": str(path), "branch": w["branch"], "age_hours": round(age_hours, 1)})
354
+ return orphans
355
+
356
+
357
+ def fix_orphaned_worktrees(project_root: Path, orphans: list[dict]) -> dict:
358
+ """Removes each orphaned worktree + its branch. Destructive -- the CLI
359
+ command owns confirmation (never called on a bare `pcp doctor`)."""
360
+ removed, errors = [], []
361
+ for o in orphans:
362
+ r1 = subprocess.run(
363
+ ["git", "worktree", "remove", o["path"], "--force"],
364
+ cwd=project_root, capture_output=True, text=True,
365
+ )
366
+ subprocess.run(["git", "branch", "-D", o["branch"]], cwd=project_root, capture_output=True, text=True)
367
+ if r1.returncode == 0:
368
+ removed.append(o["path"])
369
+ else:
370
+ errors.append(f"{o['path']}: {r1.stderr.strip()}")
371
+ return {"removed": removed, "errors": errors}
372
+
373
+
374
+ def check_timeout_heavy_criteria(pcp_dir: Path, threshold: int | None = None) -> list[str]:
375
+ """Advisory: a criterion that has already timed out `threshold`+ times
376
+ (default PCP_TIMEOUT_HEAVY_THRESHOLD, 2) across recorded builds is
377
+ unlikely to succeed on yet another retry with the same prompt/budget --
378
+ worth a human look rather than silently burning another attempt cycle.
379
+
380
+ Reads `telemetry.record(cycle="build", result="timeout", ...)` entries --
381
+ before the timeout handler in build.py was fixed to call telemetry.record()
382
+ at all, a timed-out attempt left no trace in telemetry.jsonl, so this kind
383
+ of pattern was invisible no matter how many times it recurred.
384
+ """
385
+ threshold = threshold if threshold is not None else int(
386
+ os.environ.get("PCP_TIMEOUT_HEAVY_THRESHOLD", str(DEFAULT_TIMEOUT_HEAVY_THRESHOLD))
387
+ )
388
+ from pcp import telemetry as telemetry_mod
389
+ counts: dict[tuple, int] = {}
390
+ for r in telemetry_mod.load(pcp_dir):
391
+ if r.get("cycle") == "build" and r.get("result") == "timeout":
392
+ key = (r.get("module") or "?", r.get("criterion_id") or "?")
393
+ counts[key] = counts.get(key, 0) + 1
394
+ return [
395
+ f"{mod}/{cid} has timed out {n} times across recorded builds — "
396
+ f"same prompt/budget is unlikely to succeed on another retry"
397
+ for (mod, cid), n in sorted(counts.items()) if n >= threshold
398
+ ]
399
+
400
+
401
+ def load_integrations(pcp_dir: Path) -> dict:
402
+ path = pcp_dir / "integrations.yaml"
403
+ if not path.exists():
404
+ return {}
405
+ return yaml.safe_load(path.read_text()) or {}
406
+
407
+
408
+ def check_git_hooks_reachable(project_root: Path) -> dict | None:
409
+ """Are the hooks `pcp init` installed actually reachable by git?
410
+
411
+ `pcp init` writes `commit-msg` and `post-commit` into `.git/hooks/`. If
412
+ `core.hooksPath` points somewhere else, git never looks in `.git/hooks/` at
413
+ all -- the files are present, executable, and dead. Nothing reported this, so
414
+ the failure is completely silent: `pcp scan` never re-runs after a commit and
415
+ `current_state.md` quietly ages while the project moves.
416
+
417
+ Measured across the local fleet 2026-07-30: **6 of 8 PCP-managed projects had
418
+ `core.hooksPath = ~/.git-hooks`**, a directory containing a single `commit-msg`
419
+ with no PCP reference. So PCP's Layer 1 commit-msg gate and its post-commit
420
+ scan had never fired in any of them. Project A is the clearest case --
421
+ `current_state.md` generated 2026-07-24, then 26 more commits landed and it
422
+ was never regenerated.
423
+
424
+ This is a recurrence: the same `core.hooksPath` shadowing was found and fixed
425
+ once before (see the control-catalog work), on one project, by hand. Nothing
426
+ was added to detect it, so it came back everywhere.
427
+
428
+ Note `core.hooksPath = .git/hooks` is FINE -- it resolves to the directory git
429
+ would use anyway. Only a path pointing elsewhere is a problem, which is why
430
+ this compares resolved paths rather than testing whether the config is set.
431
+
432
+ Returns None when there is nothing to say (not a git repo, or hooks are
433
+ reachable). Advisory -- never blocks.
434
+ """
435
+ git_dir = project_root / ".git"
436
+ if not git_dir.exists():
437
+ return None
438
+ installed = [h for h in ("commit-msg", "post-commit") if (git_dir / "hooks" / h).is_file()]
439
+ if not installed:
440
+ return None
441
+ try:
442
+ proc = subprocess.run(
443
+ ["git", "config", "--get", "core.hooksPath"],
444
+ cwd=project_root, capture_output=True, text=True, timeout=15,
445
+ )
446
+ except (OSError, subprocess.SubprocessError):
447
+ return None
448
+ configured = proc.stdout.strip()
449
+ if not configured:
450
+ return None # git default — .git/hooks is used
451
+ effective = (project_root / configured).resolve() if not Path(configured).is_absolute() \
452
+ else Path(configured).resolve()
453
+ if effective == (git_dir / "hooks").resolve():
454
+ return None # explicitly set to the default — still fires
455
+ # Every PCP hook is unreachable once hooksPath points elsewhere -- git reads
456
+ # only that directory. A same-NAMED file there does not rescue the hook, it
457
+ # REPLACES it: PCP's commit-msg carries the `[pcp-bypass: reason]` capture and
458
+ # the co-author-trailer strip, so an unrelated commit-msg running in its place
459
+ # is worse than none at all, and silent either way. That distinction is called
460
+ # out separately rather than being scored as "fine" -- an earlier version of
461
+ # this check reported such hooks as reachable, which is exactly the
462
+ # absence-vs-clean conflation this codebase keeps finding.
463
+ return {
464
+ "hooks_path": configured,
465
+ "installed": installed,
466
+ "unreachable": installed,
467
+ "replaced_by_other_file": [h for h in installed if (effective / h).is_file()],
468
+ }
469
+
470
+
471
+ def report_dead_git_hooks(project_root: Path) -> bool:
472
+ """Print the hooks-unreachable warning if there is one. Returns whether it fired.
473
+
474
+ Shared by `pcp doctor` and the automatic preflight so the two cannot drift --
475
+ the whole point of this check is catching a silent gap, and having it live in
476
+ only one of the two entry points would recreate one.
477
+ """
478
+ hooks = check_git_hooks_reachable(project_root)
479
+ if not hooks:
480
+ return False
481
+ console.print(
482
+ f"[yellow bold]⚠ PCP git hooks are installed but unreachable:[/yellow bold] "
483
+ f"core.hooksPath = '{hooks['hooks_path']}', so git never reads .git/hooks/ — "
484
+ f"{', '.join(hooks['unreachable'])} never fire. Layer 1's commit-msg gate and the "
485
+ f"post-commit `pcp scan` are both silently inactive, so current_state.md ages "
486
+ f"without warning."
487
+ )
488
+ if hooks["replaced_by_other_file"]:
489
+ console.print(
490
+ f"[yellow] {', '.join(hooks['replaced_by_other_file'])}: a DIFFERENT file of "
491
+ f"the same name runs from that directory instead. PCP's commit-msg carries the "
492
+ f"\\[pcp-bypass: reason] capture and the co-author-trailer strip, so a substitute "
493
+ f"is worse than none — it looks installed and enforces nothing.[/yellow]"
494
+ )
495
+ console.print(
496
+ "[dim] Fix: copy PCP's hooks into that directory (chaining any existing ones), "
497
+ "or `git config --unset core.hooksPath` if the override is not deliberate. Note a "
498
+ "GLOBAL core.hooksPath is inherited by every new repo, so `pcp init` in a fresh "
499
+ "project starts with its hooks already shadowed.[/dim]"
500
+ )
501
+ return True
502
+
503
+
504
+ def check_environment(pcp_dir: Path, fatal_on_missing_required: bool = True) -> dict:
505
+ """Non-interactive preflight — called automatically at the start of
506
+ pcp build/watch/deploy. Reports, never prompts. Fatal only on git/claude."""
507
+ tools = detect_tools()
508
+ missing_required = [t for t in REQUIRED_TOOLS if not tools[t]["available"]]
509
+ if missing_required:
510
+ console.print(f"[red bold]Missing required tool(s): {', '.join(missing_required)}[/red bold]")
511
+ console.print("[dim]Run `pcp doctor` for full environment setup.[/dim]")
512
+ if fatal_on_missing_required:
513
+ sys.exit(2)
514
+
515
+ optional_missing = [
516
+ k for k in ("test_runner", "lint", "sast", "coverage", "audit", "opa", "temporal")
517
+ if not tools[k]["available"]
518
+ ]
519
+ if optional_missing:
520
+ console.print(
521
+ f"[dim]Preflight: no {', '.join(optional_missing)} tool detected — those QA steps will skip. "
522
+ f"Run `pcp doctor` to review.[/dim]"
523
+ )
524
+
525
+ report_dead_git_hooks(pcp_dir.parent)
526
+
527
+ bloat = check_schema_bloat()
528
+ if bloat and bloat["bloated"]:
529
+ console.print(
530
+ f"[yellow bold]⚠ Postgres schema bloat:[/yellow bold] {bloat['count']} schemas matching "
531
+ f"'{bloat['pattern']}' (threshold {bloat['threshold']}) — a known cause of pytest timeouts "
532
+ f"that look like a stuck build. Run `pcp doctor --fix-schema-bloat` to clean up."
533
+ )
534
+
535
+ orphaned_wts = check_orphaned_worktrees(pcp_dir.parent)
536
+ if orphaned_wts:
537
+ console.print(
538
+ f"[yellow bold]⚠ Orphaned build worktree(s):[/yellow bold] {len(orphaned_wts)} git worktree(s) "
539
+ f"from a crashed/killed `pcp build` run with no commit activity for 24h+ — "
540
+ f"run `pcp doctor --fix-orphaned-worktrees` to clean up."
541
+ )
542
+
543
+ timeout_heavy = check_timeout_heavy_criteria(pcp_dir)
544
+ for line in timeout_heavy:
545
+ console.print(f"[yellow bold]⚠ Timeout-heavy criterion:[/yellow bold] {line}")
546
+
547
+ from pcp import build_loop_bypass, telemetry
548
+ bypass_findings = build_loop_bypass.check(pcp_dir, pcp_dir.parent)
549
+ telemetry.record(
550
+ pcp_dir, cycle="qa", check="build-loop-bypass", control_id="CTRL-037",
551
+ module=None, submodule=None, criterion_id=None,
552
+ files=[], result="pass", errors=bypass_findings, error_count=len(bypass_findings),
553
+ )
554
+ for f in bypass_findings:
555
+ console.print(f"[yellow bold]⚠ Build-loop bypass:[/yellow bold] {f}")
556
+ return tools
557
+
558
+
559
+ def _report_check_only_pre_init(project_root: Path) -> None:
560
+ """Reduced `pcp doctor --check` report for a project with no `.pcp/` yet."""
561
+ tools = detect_tools()
562
+ missing_required = [t for t in REQUIRED_TOOLS if not tools[t]["available"]]
563
+ if missing_required:
564
+ console.print(f"[red bold]Missing required tool(s): {', '.join(missing_required)}[/red bold]")
565
+ sys.exit(2)
566
+
567
+ table = Table(title="PCP Environment Check (pre-init — no .pcp/ yet)")
568
+ table.add_column("Integration")
569
+ table.add_column("Status")
570
+ table.add_column("Detail")
571
+
572
+ def _row(name: str, info: dict):
573
+ if info.get("available"):
574
+ table.add_row(name, "[green]available[/green]", str(info.get("path") or ""))
575
+ else:
576
+ table.add_row(name, "[yellow]not found[/yellow]", "—")
577
+
578
+ _row("git (required)", tools["git"])
579
+ _row("claude CLI (required)", tools["claude"])
580
+ _row("gh (CI status, for `pcp watch`)", tools["gh"])
581
+ _row("Test runner", tools["test_runner"])
582
+ _row("Lint", tools["lint"])
583
+ _row("SAST/secret-scan", tools["sast"])
584
+ console.print(table)
585
+ console.print(
586
+ "[dim]All required tools present. Everything else here is optional — the "
587
+ "corresponding QA gate skips until it's installed. Run `pcp init` or "
588
+ "`pcp takeover <vision.md>` next.[/dim]"
589
+ )
590
+ report_dead_git_hooks(project_root)
591
+
592
+
593
+ @click.command()
594
+ @click.option("--path", "project_path", type=click.Path(), default=None,
595
+ help="Project root (default: cwd, walks up to find .pcp/).")
596
+ @click.option("--check", "check_only", is_flag=True,
597
+ help="Non-interactive: detect and report only, don't prompt or write.")
598
+ @click.option("--fix-schema-bloat", "fix_bloat", is_flag=True,
599
+ help="Drop stray Postgres test schemas matching PCP_TEST_SCHEMA_PATTERN. Destructive — always confirms.")
600
+ @click.option("--fix-orphaned-worktrees", "fix_worktrees", is_flag=True,
601
+ help="Remove git worktrees left behind by a crashed/killed pcp build run. Destructive — always confirms.")
602
+ @click.option("--yes", "yes", is_flag=True, help="Skip the confirmation prompt for --fix-schema-bloat/--fix-orphaned-worktrees (CI/non-interactive use).")
603
+ def doctor(project_path: str | None, check_only: bool, fix_bloat: bool, fix_worktrees: bool, yes: bool):
604
+ """Check/configure CLI integrations for this project's PCP lifecycle."""
605
+ try:
606
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
607
+ except NoPCPDir as e:
608
+ if check_only:
609
+ # `pcp doctor --check` is documented (this module's own docstring,
610
+ # and the install SKILL.md) as blocking only on missing git/claude --
611
+ # never on a missing `.pcp/`. It's the step run BEFORE `pcp takeover`
612
+ # creates `.pcp/` on a fresh checkout, so requiring it here made the
613
+ # documented preflight fail on every fresh install. Report tools +
614
+ # version drift only; anything that needs a real .pcp/ (schema-tier
615
+ # recs, build-loop-bypass, context-route staleness) has nothing to
616
+ # check yet anyway.
617
+ _report_check_only_pre_init(Path(project_path) if project_path else Path.cwd())
618
+ return
619
+ console.print(f"[red]Error:[/red] {e}")
620
+ sys.exit(2)
621
+
622
+ project_root = pcp_dir.parent
623
+ tools = detect_tools()
624
+
625
+ bloat = check_schema_bloat()
626
+ if fix_bloat:
627
+ if not bloat:
628
+ console.print("[dim]--fix-schema-bloat: no postgres connection or psql detected — nothing to do.[/dim]")
629
+ sys.exit(0)
630
+ if bloat["count"] == 0:
631
+ console.print(f"[green]No schemas matching '{bloat['pattern']}' — nothing to drop.[/green]")
632
+ sys.exit(0)
633
+ console.print(f"[bold]{bloat['count']} schema(s)[/bold] matching '{bloat['pattern']}' will be permanently dropped.")
634
+ if not yes and not click.confirm("Proceed?", default=False):
635
+ console.print("[yellow]Aborted.[/yellow]")
636
+ sys.exit(0)
637
+ result = fix_schema_bloat(bloat["pattern"])
638
+ console.print(f"[green]Dropped {len(result['dropped'])} schema(s).[/green]")
639
+ if result["errors"]:
640
+ console.print(f"[red]{len(result['errors'])} error(s):[/red]")
641
+ for e in result["errors"][:10]:
642
+ console.print(f" {e}")
643
+ sys.exit(1 if result["errors"] else 0)
644
+
645
+ orphaned_wts = check_orphaned_worktrees(project_root)
646
+ if fix_worktrees:
647
+ if not orphaned_wts:
648
+ console.print("[green]No orphaned build worktrees found — nothing to remove.[/green]")
649
+ sys.exit(0)
650
+ console.print(f"[bold]{len(orphaned_wts)} worktree(s)[/bold] will be permanently removed (branch + working dir):")
651
+ for o in orphaned_wts:
652
+ console.print(f" {o['path']} (branch {o['branch']}, idle {o['age_hours']}h)")
653
+ if not yes and not click.confirm("Proceed?", default=False):
654
+ console.print("[yellow]Aborted.[/yellow]")
655
+ sys.exit(0)
656
+ result = fix_orphaned_worktrees(project_root, orphaned_wts)
657
+ console.print(f"[green]Removed {len(result['removed'])} worktree(s).[/green]")
658
+ if result["errors"]:
659
+ console.print(f"[red]{len(result['errors'])} error(s):[/red]")
660
+ for e in result["errors"][:10]:
661
+ console.print(f" {e}")
662
+ sys.exit(1 if result["errors"] else 0)
663
+
664
+ if bloat and bloat["bloated"]:
665
+ console.print(
666
+ f"[yellow bold]⚠ Postgres schema bloat:[/yellow bold] {bloat['count']} schemas matching "
667
+ f"'{bloat['pattern']}' (threshold {bloat['threshold']}) — run `pcp doctor --fix-schema-bloat` to clean up.\n"
668
+ )
669
+
670
+ if orphaned_wts:
671
+ console.print(
672
+ f"[yellow bold]⚠ Orphaned build worktree(s):[/yellow bold] {len(orphaned_wts)} git worktree(s) "
673
+ f"idle 24h+ with no owning build — run `pcp doctor --fix-orphaned-worktrees` to clean up.\n"
674
+ )
675
+ for o in orphaned_wts:
676
+ console.print(f" [dim]{o['path']} (branch {o['branch']}, idle {o['age_hours']}h)[/dim]")
677
+
678
+ timeout_heavy = check_timeout_heavy_criteria(pcp_dir)
679
+ for line in timeout_heavy:
680
+ console.print(f"[yellow bold]⚠ Timeout-heavy criterion:[/yellow bold] {line}")
681
+
682
+ from pcp import build_loop_bypass
683
+ for f in build_loop_bypass.check(pcp_dir, project_root):
684
+ console.print(f"[yellow bold]⚠ Build-loop bypass:[/yellow bold] {f}\n")
685
+
686
+ if report_dead_git_hooks(project_root):
687
+ console.print("")
688
+
689
+ table = Table(title="PCP Environment Check")
690
+ table.add_column("Integration")
691
+ table.add_column("Status")
692
+ table.add_column("Detail")
693
+
694
+ def _row(name: str, info: dict, key_label: str = "tool"):
695
+ if info.get("available"):
696
+ detail = info.get("path") or info.get(key_label) or ""
697
+ table.add_row(name, "[green]available[/green]", str(detail))
698
+ else:
699
+ table.add_row(name, "[yellow]not found[/yellow]", "—")
700
+
701
+ _row("git (required)", tools["git"])
702
+ _row("claude CLI (required)", tools["claude"])
703
+ _row("gh (CI status, for `pcp watch`)", tools["gh"])
704
+ _row("Test runner", tools["test_runner"])
705
+ _row("Lint", tools["lint"])
706
+ _row("SAST/secret-scan", tools["sast"])
707
+ _row("Coverage", tools["coverage"])
708
+ _row("Dead-code audit", tools["audit"])
709
+ _row("Swallowed-exception scan (ast-grep, in `pcp audit`)", tools["ast_grep"])
710
+ _row("Duplication scan (jscpd, in `pcp audit`)", tools["jscpd"])
711
+ _row("Slack notifications", tools["slack_notify"])
712
+ _row("npx (a11y scan via axe-core, CTRL-022)", tools["npx"])
713
+ _row("OPA (policy/decision layer)", tools["opa"])
714
+ _row("Temporal CLI (process layer)", tools["temporal"])
715
+ _row("agy (cross-vendor verifier, opt-in via PCP_VERIFIER_CROSS_VENDOR=1)", tools["agy"])
716
+ console.print(table)
717
+ console.print("[dim]Browser automation (for `pcp uat`): assumed available via this environment's MCP tools — not directly verified.[/dim]")
718
+
719
+ context7 = detect_context7(project_root)
720
+ c7_status = "[green]configured[/green]" if context7["configured"] else (
721
+ "[yellow]npx available, not configured[/yellow]" if context7["npx_available"]
722
+ else "[dim]npx not found[/dim]"
723
+ )
724
+ console.print(f"Context7 (live library docs for `pcp build`'s coding agent): {c7_status}")
725
+
726
+ # Context-route staleness (CTRL-021): a route resolving to zero files
727
+ # silently starves agents of context — flag it here where humans look.
728
+ from pcp import context_map
729
+ for finding in context_map.validate(pcp_dir):
730
+ console.print(f"[yellow]⚠[/yellow] {finding}")
731
+
732
+ # Rung-aware tooling recommendations (2026-07-17): projects declaring
733
+ # rung-6 criteria should schema-validate LLM output with a real library
734
+ # (Outlines = constrained decoding, strongest guarantee; Instructor =
735
+ # typed-retry, most portable); rung 4/5 declarations get pointed at the
736
+ # mature reuse-whole options by name — same shape as the Context7 offer.
737
+ _rung_recs = _rung_tooling_recommendations(pcp_dir, project_root)
738
+ for line in _rung_recs:
739
+ console.print(f"[yellow]⚠[/yellow] {line}")
740
+
741
+ # Agent-config surface audit (ECC AgentShield reference-pattern, scoped to
742
+ # a deterministic scan): the config an agent executes FROM — settings
743
+ # hooks, .mcp.json servers, instruction files — was the one surface no
744
+ # PCP gate ever looked at, even though pcp doctor itself scaffolds
745
+ # .mcp.json entries. Advisory, never blocks.
746
+ from pcp.config_audit import audit_agent_config
747
+ config_findings = audit_agent_config(project_root)
748
+ if config_findings:
749
+ console.print(f"\n[yellow bold]Agent-config audit: {len(config_findings)} finding(s)[/yellow bold]")
750
+ for f in config_findings:
751
+ console.print(f" [yellow]⚠[/yellow] {f['file']} [{f['category']}] — {f['detail']}")
752
+ else:
753
+ console.print("[dim]Agent-config audit: no secrets or suspicious commands in "
754
+ ".claude/settings*.json, .mcp.json, CLAUDE.md/AGENTS.md/GEMINI.md.[/dim]")
755
+
756
+ # Version drift (2026-07-27): PCP's own fixes reach frozen-wheel installs
757
+ # only when a human remembers to roll them, and nothing enumerated those
758
+ # installs. Surfaced four times in one session, including a served wheel
759
+ # that kept distributing a known-vulnerable version for two days, and two
760
+ # abandoned build worktrees whose own .venv sat at 0.8.6. Advisory: a
761
+ # project may legitimately pin an older PCP.
762
+ from pcp.version_drift import check as _version_drift_check
763
+ drift = _version_drift_check()
764
+ if drift["status"] == "behind":
765
+ console.print(f"\n[yellow bold]⚠ PCP version drift:[/yellow bold] {drift['message']}")
766
+ console.print(
767
+ f"[dim]This install will not have fixes made since {drift['installed']}. "
768
+ f"Reinstall from {drift.get('source_root')} to catch up.[/dim]"
769
+ )
770
+ elif drift["status"] == "code_drift":
771
+ console.print(f"\n[yellow bold]⚠ PCP code drift:[/yellow bold] {drift['message']}")
772
+ elif drift["status"] == "stale_metadata":
773
+ console.print(f"[dim]PCP version string: {drift['message']}[/dim]")
774
+
775
+ existing = load_integrations(pcp_dir)
776
+ deploy = existing.get("deploy", {})
777
+
778
+ if check_only:
779
+ return
780
+
781
+ console.print("\n[bold]Deploy configuration[/bold] (used by `pcp deploy` and `pcp watch`)")
782
+ guessed = _guess_deploy_command(project_root)
783
+ default_cmd = deploy.get("command") or guessed or ""
784
+ deploy_command = click.prompt(
785
+ "Deploy command (blank to skip)", default=default_cmd, show_default=bool(default_cmd),
786
+ )
787
+ health_url = click.prompt(
788
+ "Post-deploy health-check URL (blank to skip)", default=deploy.get("health_check_url", ""), show_default=False,
789
+ )
790
+ rollback_command = click.prompt(
791
+ "Rollback command (blank to skip)", default=deploy.get("rollback_command", ""), show_default=False,
792
+ )
793
+
794
+ if context7["npx_available"] and not context7["configured"]:
795
+ if click.confirm(
796
+ "\nEnable Context7 (live library docs injected into pcp build's coding-agent "
797
+ "context, reduces hallucinated/outdated API usage)? Adds an entry to .mcp.json.",
798
+ default=True,
799
+ ):
800
+ if configure_context7(project_root):
801
+ console.print(f"[green]✓[/green] Context7 configured in {_mcp_config_path(project_root).relative_to(project_root)}")
802
+ context7 = detect_context7(project_root)
803
+
804
+ data = {
805
+ "version": "1.0",
806
+ "checked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
807
+ "tools": tools,
808
+ "deploy": {
809
+ "command": deploy_command or None,
810
+ "health_check_url": health_url or None,
811
+ "rollback_command": rollback_command or None,
812
+ },
813
+ "browser_automation": {"assumed_available": True},
814
+ "context7": context7,
815
+ "agent_config_audit": {"findings": len(config_findings)},
816
+ "version_drift": drift,
817
+ }
818
+ out = pcp_dir / "integrations.yaml"
819
+ out.write_text(yaml.dump(data, default_flow_style=False))
820
+ console.print(f"\n[green]✓[/green] Saved → {out.relative_to(project_root)}")