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/watch.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""pcp watch — continuous CI/deploy monitoring.
|
|
2
|
+
|
|
3
|
+
Polls every N seconds (default 270, per docs/greenfield.md) for a new CI run
|
|
4
|
+
result and deploy health. On failure: fetches logs, spawns a fresh coding
|
|
5
|
+
agent to diagnose+fix+commit+push, notifies via slack-notify if available.
|
|
6
|
+
The fix isn't verified here — the next poll cycle re-checks CI for it, same
|
|
7
|
+
as a human would push a fix and watch CI pick it up.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
23
|
+
from pcp.commands.doctor import load_integrations, check_environment
|
|
24
|
+
from pcp.commands.build import check_agent_depth_or_exit
|
|
25
|
+
from pcp.llm.client import _claude_bin
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
DEFAULT_INTERVAL = 270
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_max_iterations() -> int:
|
|
33
|
+
"""Overall poll-loop ceiling — a daemon left running for days should stop
|
|
34
|
+
on its own, not rely on someone remembering it's up. Default ~15h at the
|
|
35
|
+
default 270s interval. Override with PCP_WATCH_MAX_ITERATIONS."""
|
|
36
|
+
return int(os.environ.get("PCP_WATCH_MAX_ITERATIONS", "200"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _max_consecutive_auto_fix_attempts() -> int:
|
|
40
|
+
"""Regression-loop breaker: if auto-fix keeps getting attempted without a
|
|
41
|
+
CI success in between, a fresh agent is likely re-diagnosing from scratch
|
|
42
|
+
each time and could be fixing X by breaking Y, then fixing Y by breaking X.
|
|
43
|
+
After this many consecutive attempts with no success, stop auto-fixing
|
|
44
|
+
and just report — a human needs to look. Override with
|
|
45
|
+
PCP_WATCH_MAX_CONSECUTIVE_FIXES."""
|
|
46
|
+
return int(os.environ.get("PCP_WATCH_MAX_CONSECUTIVE_FIXES", "3"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _watch_agent_max_budget_usd() -> str:
|
|
50
|
+
"""Per-attempt dollar cap passed to `claude -p --max-budget-usd` for
|
|
51
|
+
watch's auto-fix agent -- same mechanism build.py already uses for its
|
|
52
|
+
own coding-agent subprocess, applied here since this is the same shape of
|
|
53
|
+
call and had no cap at all until now. Doesn't add a new retry pathway:
|
|
54
|
+
an attempt that hits this cap without finishing is indistinguishable
|
|
55
|
+
from any other incomplete attempt, and already falls under the existing
|
|
56
|
+
PCP_WATCH_MAX_CONSECUTIVE_FIXES ceiling below -- it just bounds the cost
|
|
57
|
+
of each individual attempt within that ceiling instead of leaving it open.
|
|
58
|
+
Override with PCP_WATCH_AGENT_MAX_BUDGET_USD."""
|
|
59
|
+
return os.environ.get("PCP_WATCH_AGENT_MAX_BUDGET_USD", "5")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_latest_ci_run(project_root: Path) -> dict | None:
|
|
63
|
+
if not shutil.which("gh"):
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["gh", "run", "list", "--limit", "1", "--json", "status,conclusion,databaseId,headBranch,name,url"],
|
|
68
|
+
capture_output=True, text=True, cwd=project_root, timeout=30,
|
|
69
|
+
)
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
return None
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
runs = json.loads(result.stdout)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return None
|
|
78
|
+
return runs[0] if runs else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_failed_logs(project_root: Path, run_id) -> str:
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["gh", "run", "view", str(run_id), "--log-failed"],
|
|
85
|
+
capture_output=True, text=True, cwd=project_root, timeout=60,
|
|
86
|
+
)
|
|
87
|
+
except subprocess.TimeoutExpired:
|
|
88
|
+
return "(timed out fetching logs)"
|
|
89
|
+
return (result.stdout + result.stderr)[-8000:]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_deploy_health(health_url: str | None) -> bool | None:
|
|
93
|
+
"""True/False if checked, None if no URL configured."""
|
|
94
|
+
if not health_url:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
import urllib.request
|
|
98
|
+
req = urllib.request.Request(health_url, method="GET")
|
|
99
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
100
|
+
return 200 <= resp.status < 300
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Heartbeat file for the dead-man's-switch below. Set by watch() at startup;
|
|
106
|
+
# notify() records every attempt + every success so a checker can detect
|
|
107
|
+
# "attempts happening, successes not" — the failure class an in-process
|
|
108
|
+
# try/except structurally cannot catch about itself (lived incident: SSL cert
|
|
109
|
+
# error silently fell back to log-only for 8 days).
|
|
110
|
+
_HEARTBEAT_DIR: Path | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def set_heartbeat_dir(pcp_dir: Path) -> None:
|
|
114
|
+
global _HEARTBEAT_DIR
|
|
115
|
+
_HEARTBEAT_DIR = pcp_dir
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _heartbeat_record(field: str) -> None:
|
|
119
|
+
if _HEARTBEAT_DIR is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
import yaml
|
|
123
|
+
path = _HEARTBEAT_DIR / "notify_heartbeat.yaml"
|
|
124
|
+
data = {}
|
|
125
|
+
if path.exists():
|
|
126
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
127
|
+
data[field] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
128
|
+
path.write_text(yaml.dump(data))
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_notify_heartbeat(pcp_dir: Path, lag_hours: float = 1.0) -> str | None:
|
|
134
|
+
"""Dead-man's-switch: if notification attempts keep happening but the last
|
|
135
|
+
SUCCESS lags the last attempt by more than lag_hours, the pipeline is
|
|
136
|
+
failing persistently — return a warning string (caller screams on console;
|
|
137
|
+
notifying about a broken notifier is structurally pointless)."""
|
|
138
|
+
try:
|
|
139
|
+
import yaml
|
|
140
|
+
path = pcp_dir / "notify_heartbeat.yaml"
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return None
|
|
143
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
144
|
+
attempt, success = data.get("last_attempt"), data.get("last_success")
|
|
145
|
+
if not attempt:
|
|
146
|
+
return None
|
|
147
|
+
fmt = "%Y-%m-%dT%H:%M:%SZ"
|
|
148
|
+
attempt_ts = time.mktime(time.strptime(attempt, fmt))
|
|
149
|
+
success_ts = time.mktime(time.strptime(success, fmt)) if success else 0
|
|
150
|
+
if attempt_ts - success_ts > lag_hours * 3600:
|
|
151
|
+
return (
|
|
152
|
+
f"Notification pipeline appears BROKEN: last successful delivery "
|
|
153
|
+
f"{'never' if not success else success}, but attempts continue (last {attempt}). "
|
|
154
|
+
"Humans are NOT being reached — check slack-notify."
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
return None
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def notify(message: str) -> None:
|
|
162
|
+
"""Send via slack-notify if available; NEVER fail silently. A delivery
|
|
163
|
+
failure downgrading to console-only without saying so is a lived incident
|
|
164
|
+
class here (an SSL cert error once silently fell back to log-only and a
|
|
165
|
+
security STOP sat unread for 8 days) — if delivery fails, say so loudly
|
|
166
|
+
so the console record itself shows the human was probably NOT reached."""
|
|
167
|
+
delivered = False
|
|
168
|
+
if shutil.which("slack-notify"):
|
|
169
|
+
_heartbeat_record("last_attempt")
|
|
170
|
+
try:
|
|
171
|
+
result = subprocess.run(["slack-notify", message], capture_output=True, text=True, timeout=15)
|
|
172
|
+
delivered = result.returncode == 0
|
|
173
|
+
if delivered:
|
|
174
|
+
_heartbeat_record("last_success")
|
|
175
|
+
if not delivered:
|
|
176
|
+
console.print(
|
|
177
|
+
f"[red bold]Notification delivery FAILED[/red bold] "
|
|
178
|
+
f"(slack-notify exit {result.returncode}: {(result.stderr or result.stdout).strip()[:200]}) "
|
|
179
|
+
"— message below reached the console ONLY, a human has likely not seen it."
|
|
180
|
+
)
|
|
181
|
+
except subprocess.TimeoutExpired:
|
|
182
|
+
console.print(
|
|
183
|
+
"[red bold]Notification delivery FAILED[/red bold] (slack-notify timed out) "
|
|
184
|
+
"— message below reached the console ONLY, a human has likely not seen it."
|
|
185
|
+
)
|
|
186
|
+
console.print(f"[dim]Notify: {message}[/dim]")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def check_stale_escalations(pcp_dir: Path, already_reported: set) -> None:
|
|
190
|
+
"""Escalation-acknowledgment watchdog: an escalation recorded but never
|
|
191
|
+
acted on (criterion still pending past PCP_ESCALATION_STALE_HOURS,
|
|
192
|
+
default 24h) gets re-surfaced — recording an escalation is not the same
|
|
193
|
+
fact as a human having seen it. Each stale entry is re-notified once per
|
|
194
|
+
watch run, not once per poll (notification fatigue is its own failure
|
|
195
|
+
mode: a team trained to ignore pings misses the real one)."""
|
|
196
|
+
from pcp import escalations
|
|
197
|
+
for e in escalations.find_stale(pcp_dir):
|
|
198
|
+
key = (e.get("module"), e.get("criterion_id"), e.get("timestamp"))
|
|
199
|
+
if key in already_reported:
|
|
200
|
+
continue
|
|
201
|
+
already_reported.add(key)
|
|
202
|
+
state = e.get("state", "unacked")
|
|
203
|
+
detail = (
|
|
204
|
+
"no human has acknowledged it" if state == "unacked"
|
|
205
|
+
else "acknowledged but still unresolved (seen is not fixed)"
|
|
206
|
+
)
|
|
207
|
+
msg = (
|
|
208
|
+
f"pcp watch: STALE ESCALATION [{state}] — {e.get('module')}/{e.get('criterion_id')} "
|
|
209
|
+
f"({e.get('category', 'uncategorized')}) waiting {e.get('age_hours')}h; {detail}. "
|
|
210
|
+
f"Ack with: pcp escalations --ack {e.get('module')}/{e.get('criterion_id')}"
|
|
211
|
+
)
|
|
212
|
+
console.print(f"[red bold]{msg}[/red bold]")
|
|
213
|
+
notify(msg)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def attempt_auto_fix(pcp_dir: Path, failure_context: str, session_id: str, is_first_attempt: bool) -> bool:
|
|
217
|
+
"""Spawn a claude -p session instructed to diagnose+fix+commit+push.
|
|
218
|
+
|
|
219
|
+
session_id ties consecutive auto-fix attempts within the same failure
|
|
220
|
+
streak together: is_first_attempt=True opens a fresh session with
|
|
221
|
+
--session-id, subsequent consecutive attempts --resume it instead of
|
|
222
|
+
cold-restarting -- the same fix build.py already applies to its own
|
|
223
|
+
per-criterion retries (a cold restart re-explores the whole repo and
|
|
224
|
+
re-pastes context for every attempt). The caller resets to a new
|
|
225
|
+
session_id once CI succeeds, so a genuinely new failure never resumes
|
|
226
|
+
stale context from an unrelated one.
|
|
227
|
+
|
|
228
|
+
Returns True if the agent ran without a process-level error — not a
|
|
229
|
+
guarantee the fix worked, the next poll cycle re-checks CI for that."""
|
|
230
|
+
prompt = (
|
|
231
|
+
"A CI run failed on this repository. FIRST classify the failure from the log below "
|
|
232
|
+
"as exactly one of: CODE (a real defect in application/test logic), FLAKY (test "
|
|
233
|
+
"passes/fails non-deterministically — timing, ordering, network, shared state), or "
|
|
234
|
+
"INFRA (runner/tooling/dependency-resolution problem outside the code). State the "
|
|
235
|
+
"classification and your evidence for it before doing anything else.\n"
|
|
236
|
+
"- CODE: fix the underlying defect, commit, and push to the current branch.\n"
|
|
237
|
+
"- FLAKY: do NOT patch application code to make the symptom go away — masking an "
|
|
238
|
+
"unreliable test with application changes creates debt and hides the real problem. "
|
|
239
|
+
"Quarantine the test instead (mark it skipped/xfail with a comment naming the "
|
|
240
|
+
"flakiness evidence), commit that, and say clearly a human needs to fix the test's "
|
|
241
|
+
"underlying non-determinism.\n"
|
|
242
|
+
"- INFRA: do NOT change application code. Fix the workflow/tooling config only if "
|
|
243
|
+
"the cause is unambiguous from the log; otherwise change nothing and report what "
|
|
244
|
+
"you found.\n"
|
|
245
|
+
"Follow this project's CLAUDE.md and ci_rules.yaml.\n\n"
|
|
246
|
+
f"## CI Failure Log\n```\n{failure_context}\n```\n"
|
|
247
|
+
)
|
|
248
|
+
session_flag = ["--session-id", session_id] if is_first_attempt else ["--resume", session_id]
|
|
249
|
+
cmd = [
|
|
250
|
+
_claude_bin(), "-p",
|
|
251
|
+
"--permission-mode", "acceptEdits",
|
|
252
|
+
"--output-format", "json",
|
|
253
|
+
"--max-budget-usd", _watch_agent_max_budget_usd(),
|
|
254
|
+
*session_flag,
|
|
255
|
+
]
|
|
256
|
+
build_model = os.environ.get("PCP_BUILD_MODEL")
|
|
257
|
+
if build_model:
|
|
258
|
+
cmd += ["--model", build_model]
|
|
259
|
+
try:
|
|
260
|
+
result = subprocess.run(
|
|
261
|
+
cmd, input=prompt, text=True, capture_output=True, cwd=pcp_dir.parent, timeout=600,
|
|
262
|
+
)
|
|
263
|
+
except subprocess.TimeoutExpired:
|
|
264
|
+
return False
|
|
265
|
+
return result.returncode == 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@click.command()
|
|
269
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
270
|
+
@click.option("--interval", default=DEFAULT_INTERVAL, show_default=True, help="Poll interval in seconds.")
|
|
271
|
+
@click.option("--once", is_flag=True, help="Single pass, no loop/sleep — for testing or one-shot checks.")
|
|
272
|
+
@click.option("--max-iterations", default=None, type=int, help="Stop after N polls (default: PCP_WATCH_MAX_ITERATIONS env or 200).")
|
|
273
|
+
@click.option("--report-only", is_flag=True,
|
|
274
|
+
help="Report + notify on failures but never spawn a fix agent. Recommended first "
|
|
275
|
+
"phase on a new project — measure diagnosis signal quality before granting "
|
|
276
|
+
"auto-fix. Also enabled via PCP_WATCH_REPORT_ONLY=1.")
|
|
277
|
+
def watch(project_path: str | None, interval: int, once: bool, max_iterations: int | None, report_only: bool):
|
|
278
|
+
"""Continuously monitor CI + deploy health, auto-diagnose and fix failures."""
|
|
279
|
+
try:
|
|
280
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
281
|
+
except NoPCPDir as e:
|
|
282
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
283
|
+
sys.exit(2)
|
|
284
|
+
|
|
285
|
+
project_root = pcp_dir.parent
|
|
286
|
+
check_environment(pcp_dir, fatal_on_missing_required=False)
|
|
287
|
+
check_agent_depth_or_exit()
|
|
288
|
+
integrations = load_integrations(pcp_dir)
|
|
289
|
+
health_url = (integrations.get("deploy") or {}).get("health_check_url")
|
|
290
|
+
|
|
291
|
+
if not shutil.which("gh") and not health_url:
|
|
292
|
+
console.print("[yellow]Neither `gh` CLI nor a configured health-check URL found — nothing to watch.[/yellow]")
|
|
293
|
+
console.print("[dim]Run `pcp doctor` to configure a deploy health-check URL.[/dim]")
|
|
294
|
+
sys.exit(0)
|
|
295
|
+
|
|
296
|
+
set_heartbeat_dir(pcp_dir)
|
|
297
|
+
hb_warning = check_notify_heartbeat(pcp_dir)
|
|
298
|
+
if hb_warning:
|
|
299
|
+
console.print(f"[red bold]{hb_warning}[/red bold]")
|
|
300
|
+
|
|
301
|
+
report_only = report_only or os.environ.get("PCP_WATCH_REPORT_ONLY", "") in ("1", "true", "yes")
|
|
302
|
+
if report_only:
|
|
303
|
+
console.print("[cyan]Report-only mode: failures will be reported/notified, no fix agent will be spawned.[/cyan]")
|
|
304
|
+
|
|
305
|
+
max_iterations = max_iterations or _default_max_iterations()
|
|
306
|
+
max_consecutive_fixes = _max_consecutive_auto_fix_attempts()
|
|
307
|
+
last_seen_run_id = None
|
|
308
|
+
iteration = 0
|
|
309
|
+
consecutive_fix_attempts = 0
|
|
310
|
+
auto_fix_disabled = False
|
|
311
|
+
fix_session_id = None
|
|
312
|
+
stale_escalations_reported: set = set()
|
|
313
|
+
|
|
314
|
+
while True:
|
|
315
|
+
iteration += 1
|
|
316
|
+
console.print(f"[dim]Watch poll #{iteration}/{max_iterations} ({time.strftime('%H:%M:%S')})...[/dim]")
|
|
317
|
+
|
|
318
|
+
run = get_latest_ci_run(project_root)
|
|
319
|
+
if run and run.get("databaseId") != last_seen_run_id:
|
|
320
|
+
last_seen_run_id = run.get("databaseId")
|
|
321
|
+
if run.get("status") == "completed" and run.get("conclusion") not in ("success", None):
|
|
322
|
+
console.print(f"[red]CI run failed:[/red] {run.get('name')} — {run.get('url')}")
|
|
323
|
+
if report_only:
|
|
324
|
+
notify(f"pcp watch (report-only): CI run failed — {run.get('name')} {run.get('url')}")
|
|
325
|
+
elif auto_fix_disabled:
|
|
326
|
+
notify(f"pcp watch: CI still failing after {max_consecutive_fixes} auto-fix attempts — auto-fix paused, needs human attention. {run.get('url')}")
|
|
327
|
+
else:
|
|
328
|
+
from pcp import spend
|
|
329
|
+
allowed, spend_reason = spend.check_ceiling(pcp_dir)
|
|
330
|
+
if not allowed:
|
|
331
|
+
console.print(f"[red bold]Project spend ceiling reached:[/red bold] {spend_reason}")
|
|
332
|
+
notify(f"pcp watch: CI failed but auto-fix skipped — {spend_reason}. {run.get('url')}")
|
|
333
|
+
else:
|
|
334
|
+
logs = get_failed_logs(project_root, run["databaseId"])
|
|
335
|
+
console.print("[dim]Attempting auto-fix...[/dim]")
|
|
336
|
+
is_first_attempt = fix_session_id is None
|
|
337
|
+
if is_first_attempt:
|
|
338
|
+
fix_session_id = str(uuid.uuid4())
|
|
339
|
+
fixed = attempt_auto_fix(pcp_dir, logs, fix_session_id, is_first_attempt)
|
|
340
|
+
consecutive_fix_attempts += 1
|
|
341
|
+
if fixed:
|
|
342
|
+
notify(f"pcp watch: auto-fix attempted for failed CI run {run.get('url')} — pushed, awaiting next CI result.")
|
|
343
|
+
else:
|
|
344
|
+
notify(f"pcp watch: CI run failed and auto-fix attempt errored — needs human attention. {run.get('url')}")
|
|
345
|
+
if consecutive_fix_attempts >= max_consecutive_fixes:
|
|
346
|
+
auto_fix_disabled = True
|
|
347
|
+
notify(
|
|
348
|
+
f"pcp watch: {consecutive_fix_attempts} consecutive auto-fix attempts without a CI success — "
|
|
349
|
+
"pausing auto-fix (possible regression loop). Still watching and reporting status."
|
|
350
|
+
)
|
|
351
|
+
elif run.get("status") == "completed" and run.get("conclusion") == "success":
|
|
352
|
+
console.print(f"[green]CI run succeeded:[/green] {run.get('name')}")
|
|
353
|
+
consecutive_fix_attempts = 0
|
|
354
|
+
auto_fix_disabled = False
|
|
355
|
+
fix_session_id = None
|
|
356
|
+
|
|
357
|
+
check_stale_escalations(pcp_dir, stale_escalations_reported)
|
|
358
|
+
|
|
359
|
+
if health_url:
|
|
360
|
+
healthy = check_deploy_health(health_url)
|
|
361
|
+
if healthy is False:
|
|
362
|
+
console.print(f"[red]Deploy health check FAILED:[/red] {health_url}")
|
|
363
|
+
notify(f"pcp watch: deploy health check failing at {health_url}")
|
|
364
|
+
elif healthy is True:
|
|
365
|
+
console.print(f"[green]Deploy healthy:[/green] {health_url}")
|
|
366
|
+
|
|
367
|
+
if once:
|
|
368
|
+
break
|
|
369
|
+
if iteration >= max_iterations:
|
|
370
|
+
console.print(f"[yellow]Reached max iterations ({max_iterations}) — stopping. Override with --max-iterations or PCP_WATCH_MAX_ITERATIONS.[/yellow]")
|
|
371
|
+
break
|
|
372
|
+
time.sleep(interval)
|
pcp/config_audit.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Agent-config surface audit — deterministic, no LLM, no external tool.
|
|
2
|
+
|
|
3
|
+
PCP's QA gates scan the *built code* (SAST/secret-scan via semgrep), but
|
|
4
|
+
nothing audited the project's *agent-facing configuration* — the surface an
|
|
5
|
+
agent actually executes from: `.claude/settings.json` hooks (arbitrary shell
|
|
6
|
+
commands), `.mcp.json` servers (which `pcp doctor` itself now scaffolds for
|
|
7
|
+
Context7), and instruction files (CLAUDE.md/AGENTS.md). Reference-pattern
|
|
8
|
+
from ECC's AgentShield (affaan-m/ECC), scoped down to a deterministic scan:
|
|
9
|
+
secret literals, and hook/server commands that fetch-and-execute remote code.
|
|
10
|
+
|
|
11
|
+
Advisory only — surfaced by `pcp doctor`, never blocks. A finding here is a
|
|
12
|
+
signal for human review, same posture as `pcp audit`'s dead-code findings.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Anchored to real token shapes, not bare prefixes — `sk-` alone would flag
|
|
20
|
+
# prose. Patterns matched against raw file text.
|
|
21
|
+
SECRET_PATTERNS: list[tuple[str, str]] = [
|
|
22
|
+
("Anthropic API key", r"sk-ant-[A-Za-z0-9_-]{20,}"),
|
|
23
|
+
("OpenAI-style API key", r"sk-[A-Za-z0-9]{20,}"),
|
|
24
|
+
("GitHub token", r"gh[pousr]_[A-Za-z0-9]{36,}"),
|
|
25
|
+
("GitHub fine-grained PAT", r"github_pat_[A-Za-z0-9_]{22,}"),
|
|
26
|
+
("AWS access key", r"AKIA[0-9A-Z]{16}"),
|
|
27
|
+
("Slack token", r"xox[bpoas]-[0-9A-Za-z-]{10,}"),
|
|
28
|
+
("Google API key", r"AIza[0-9A-Za-z_-]{35}"),
|
|
29
|
+
("Private key block", r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
SUSPICIOUS_COMMAND_PATTERNS: list[tuple[str, str]] = [
|
|
33
|
+
("remote script piped to shell", r"(curl|wget)[^|;&\n]*\|\s*(ba|z)?sh"),
|
|
34
|
+
("base64-decoded payload piped to shell", r"base64\s+(-d|--decode)[^|\n]*\|\s*(ba|z)?sh"),
|
|
35
|
+
("recursive delete from root/home", r"rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+[/~]"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
|
|
39
|
+
SETTINGS_FILES = (".claude/settings.json", ".claude/settings.local.json")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _finding(file: str, category: str, detail: str) -> dict:
|
|
43
|
+
return {"file": file, "category": category, "detail": detail}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _scan_text_for_secrets(text: str, rel_path: str) -> list[dict]:
|
|
47
|
+
findings = []
|
|
48
|
+
for name, pattern in SECRET_PATTERNS:
|
|
49
|
+
for m in re.finditer(pattern, text):
|
|
50
|
+
token = m.group(0)
|
|
51
|
+
findings.append(_finding(
|
|
52
|
+
rel_path, "secret",
|
|
53
|
+
f"{name} literal ({token[:8]}…{token[-4:]}) — move to an env var/secret store",
|
|
54
|
+
))
|
|
55
|
+
return findings
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _scan_text_for_suspicious_commands(text: str, rel_path: str, context: str) -> list[dict]:
|
|
59
|
+
findings = []
|
|
60
|
+
for name, pattern in SUSPICIOUS_COMMAND_PATTERNS:
|
|
61
|
+
if re.search(pattern, text):
|
|
62
|
+
findings.append(_finding(
|
|
63
|
+
rel_path, "suspicious-command",
|
|
64
|
+
f"{name} in {context} — an agent session executes this without review",
|
|
65
|
+
))
|
|
66
|
+
return findings
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _iter_hook_commands(settings: dict):
|
|
70
|
+
"""Yield every command string from Claude Code's hooks structure:
|
|
71
|
+
{"hooks": {"<Event>": [{"hooks": [{"type": "command", "command": "..."}]}]}}"""
|
|
72
|
+
for event_entries in (settings.get("hooks") or {}).values():
|
|
73
|
+
if not isinstance(event_entries, list):
|
|
74
|
+
continue
|
|
75
|
+
for entry in event_entries:
|
|
76
|
+
for h in (entry.get("hooks") or []) if isinstance(entry, dict) else []:
|
|
77
|
+
if isinstance(h, dict) and h.get("command"):
|
|
78
|
+
yield str(h["command"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _audit_settings_file(project_root: Path, rel_path: str) -> list[dict]:
|
|
82
|
+
path = project_root / rel_path
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return []
|
|
85
|
+
text = path.read_text(errors="replace")
|
|
86
|
+
findings = _scan_text_for_secrets(text, rel_path)
|
|
87
|
+
try:
|
|
88
|
+
settings = json.loads(text)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
findings.append(_finding(rel_path, "unparseable", "not valid JSON — hooks could not be audited"))
|
|
91
|
+
return findings
|
|
92
|
+
for cmd in _iter_hook_commands(settings):
|
|
93
|
+
findings.extend(_scan_text_for_suspicious_commands(cmd, rel_path, "a hook command"))
|
|
94
|
+
return findings
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _audit_mcp_config(project_root: Path) -> list[dict]:
|
|
98
|
+
path = project_root / ".mcp.json"
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return []
|
|
101
|
+
text = path.read_text(errors="replace")
|
|
102
|
+
findings = _scan_text_for_secrets(text, ".mcp.json")
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(text)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
findings.append(_finding(".mcp.json", "unparseable", "not valid JSON — servers could not be audited"))
|
|
107
|
+
return findings
|
|
108
|
+
for server_name, server in (data.get("mcpServers") or {}).items():
|
|
109
|
+
if not isinstance(server, dict):
|
|
110
|
+
continue
|
|
111
|
+
blob = " ".join([str(server.get("command", ""))] + [str(a) for a in server.get("args", [])])
|
|
112
|
+
findings.extend(_scan_text_for_suspicious_commands(
|
|
113
|
+
blob, ".mcp.json", f"MCP server '{server_name}' launch command"))
|
|
114
|
+
for env_key, env_val in (server.get("env") or {}).items():
|
|
115
|
+
for name, pattern in SECRET_PATTERNS:
|
|
116
|
+
if re.fullmatch(pattern, str(env_val)):
|
|
117
|
+
findings.append(_finding(
|
|
118
|
+
".mcp.json", "secret",
|
|
119
|
+
f"MCP server '{server_name}' env {env_key} holds an inline {name} — "
|
|
120
|
+
"reference an environment variable instead",
|
|
121
|
+
))
|
|
122
|
+
return findings
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def audit_agent_config(project_root: Path) -> list[dict]:
|
|
126
|
+
"""Full sweep. Returns findings, deduplicated, stable order."""
|
|
127
|
+
findings: list[dict] = []
|
|
128
|
+
for rel in SETTINGS_FILES:
|
|
129
|
+
findings.extend(_audit_settings_file(project_root, rel))
|
|
130
|
+
findings.extend(_audit_mcp_config(project_root))
|
|
131
|
+
for rel in INSTRUCTION_FILES:
|
|
132
|
+
path = project_root / rel
|
|
133
|
+
if path.exists():
|
|
134
|
+
findings.extend(_scan_text_for_secrets(path.read_text(errors="replace"), rel))
|
|
135
|
+
seen, unique = set(), []
|
|
136
|
+
for f in findings:
|
|
137
|
+
key = (f["file"], f["category"], f["detail"])
|
|
138
|
+
if key not in seen:
|
|
139
|
+
seen.add(key)
|
|
140
|
+
unique.append(f)
|
|
141
|
+
return unique
|
pcp/context_map.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Deterministic context routing — scenario → files an agent should read.
|
|
2
|
+
|
|
3
|
+
2026-07-18, from the context-contamination review. PCP already routed agent
|
|
4
|
+
context in code (prompt builders pointing at specific files per scenario),
|
|
5
|
+
but the routing was scattered and unauditable. `.pcp/context_map.yaml` makes
|
|
6
|
+
it one declarative, reviewable, drift-checkable table.
|
|
7
|
+
|
|
8
|
+
Principles (the review's three amendments, enforced by design):
|
|
9
|
+
- Scenario detection stays rung-1: scenarios are keyed off schema fields
|
|
10
|
+
(logic_tier present, UI keyword match, module name) — never an LLM call.
|
|
11
|
+
- Intent files are routed WHOLE (objective/spec) — fragmenting spec content
|
|
12
|
+
measurably collapses agent faithfulness (SLUMP, arXiv:2603.17104).
|
|
13
|
+
- Sliced state is always a GENERATED projection of a canonical source
|
|
14
|
+
(module docs/built.md is regenerated from acceptance.yaml), never a
|
|
15
|
+
hand-maintained copy — divergence is structurally impossible.
|
|
16
|
+
|
|
17
|
+
A missing context_map.yaml falls back to the built-in defaults below, so
|
|
18
|
+
projects scaffolded before this feature keep working unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
CONTEXT_MAP_FILE = "context_map.yaml"
|
|
26
|
+
|
|
27
|
+
# Built-in defaults — mirror the routing the prompt builders used before the
|
|
28
|
+
# table existed. `{module}` is substituted at resolve time. `fallback` is
|
|
29
|
+
# used only when none of the primary files exist.
|
|
30
|
+
DEFAULT_ROUTES: dict[str, dict] = {
|
|
31
|
+
"always": {
|
|
32
|
+
"files": [".pcp/objective.md", ".pcp/architecture.md", ".pcp/architect_persona.md"],
|
|
33
|
+
},
|
|
34
|
+
"module_state": {
|
|
35
|
+
# The module's own generated state slice — NOT program-wide
|
|
36
|
+
# current_state.md, which on a many-module project is mostly other
|
|
37
|
+
# modules' context (the contamination the review named).
|
|
38
|
+
"files": [".pcp/strategy/modules/{module}/docs/built.md"],
|
|
39
|
+
"fallback": [".pcp/current_state.md"],
|
|
40
|
+
},
|
|
41
|
+
"ui_facing": {
|
|
42
|
+
"files": [".pcp/design_system.md"],
|
|
43
|
+
},
|
|
44
|
+
"logic_tier_declared": {
|
|
45
|
+
"files": [".pcp/logic_tier_guide.md"],
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(pcp_dir: Path) -> dict[str, dict]:
|
|
51
|
+
path = pcp_dir / CONTEXT_MAP_FILE
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return DEFAULT_ROUTES
|
|
54
|
+
try:
|
|
55
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
56
|
+
except yaml.YAMLError:
|
|
57
|
+
return DEFAULT_ROUTES
|
|
58
|
+
routes = data.get("routes")
|
|
59
|
+
if not isinstance(routes, dict) or not routes:
|
|
60
|
+
return DEFAULT_ROUTES
|
|
61
|
+
return routes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve(pcp_dir: Path, scenario: str, module: str | None = None) -> list[str]:
|
|
65
|
+
"""Project-root-relative paths that exist for this scenario. Primary
|
|
66
|
+
files first; fallback only when NO primary file exists."""
|
|
67
|
+
routes = load(pcp_dir)
|
|
68
|
+
route = routes.get(scenario)
|
|
69
|
+
if not route:
|
|
70
|
+
return []
|
|
71
|
+
project_root = pcp_dir.parent
|
|
72
|
+
|
|
73
|
+
def _expand(paths: list) -> list[str]:
|
|
74
|
+
out = []
|
|
75
|
+
for p in paths or []:
|
|
76
|
+
p = str(p)
|
|
77
|
+
if "{module}" in p:
|
|
78
|
+
if not module:
|
|
79
|
+
continue
|
|
80
|
+
p = p.replace("{module}", module)
|
|
81
|
+
if (project_root / p).exists():
|
|
82
|
+
out.append(p)
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
primary = _expand(route.get("files", []))
|
|
86
|
+
if primary:
|
|
87
|
+
return primary
|
|
88
|
+
return _expand(route.get("fallback", []))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate(pcp_dir: Path, known_modules: list[str] | None = None) -> list[str]:
|
|
92
|
+
"""Staleness check (CTRL-021): every routed path must resolve to at least
|
|
93
|
+
one existing file (for {module} templates: for at least one known module,
|
|
94
|
+
or via fallback). A route pointing at nothing silently starves agents of
|
|
95
|
+
context — worse and less visible than over-feeding."""
|
|
96
|
+
findings = []
|
|
97
|
+
routes = load(pcp_dir)
|
|
98
|
+
project_root = pcp_dir.parent
|
|
99
|
+
modules = known_modules or [p.name for p in (pcp_dir / "strategy" / "modules").glob("*") if p.is_dir()]
|
|
100
|
+
|
|
101
|
+
for scenario, route in routes.items():
|
|
102
|
+
if not isinstance(route, dict):
|
|
103
|
+
findings.append(f"context_map: route '{scenario}' is not a mapping")
|
|
104
|
+
continue
|
|
105
|
+
all_paths = list(route.get("files", [])) + list(route.get("fallback", []))
|
|
106
|
+
if not all_paths:
|
|
107
|
+
findings.append(f"context_map: route '{scenario}' lists no files")
|
|
108
|
+
continue
|
|
109
|
+
any_exists = False
|
|
110
|
+
for p in all_paths:
|
|
111
|
+
p = str(p)
|
|
112
|
+
if "{module}" in p:
|
|
113
|
+
if any((project_root / p.replace("{module}", m)).exists() for m in modules):
|
|
114
|
+
any_exists = True
|
|
115
|
+
break
|
|
116
|
+
elif (project_root / p).exists():
|
|
117
|
+
any_exists = True
|
|
118
|
+
break
|
|
119
|
+
if not any_exists:
|
|
120
|
+
findings.append(
|
|
121
|
+
f"context_map: route '{scenario}' resolves to zero existing files "
|
|
122
|
+
f"({', '.join(str(p) for p in all_paths[:3])}) — agents routed here get nothing"
|
|
123
|
+
)
|
|
124
|
+
return findings
|