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/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