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.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- 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)}")
|