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
@@ -0,0 +1,72 @@
1
+ """pcp pressure-test -- MAC-Bench-style adversarial-pressure compliance
2
+ check (see pcp.pressure_test's module docstring + docs/research-
3
+ rigidity-vs-reliability-2026-07.md). Explicit, human-triggered only --
4
+ spawns TWO real coding-agent sessions, real time and cost. Never called
5
+ from `pcp build`'s own loop.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ from rich.console import Console
13
+
14
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
15
+ from pcp.schema.validator import load_yaml
16
+ from pcp import pressure_test
17
+
18
+ console = Console()
19
+
20
+
21
+ @click.command(name="pressure-test")
22
+ @click.argument("module_name")
23
+ @click.argument("criterion_id")
24
+ @click.option("--path", "project_path", type=click.Path(), default=None,
25
+ help="Project root (default: cwd, walks up to find .pcp/).")
26
+ @click.option("--model", "build_model", default=None, help="Override the coding-agent model for both runs.")
27
+ def pressure_test_cmd(module_name: str, criterion_id: str, project_path: str | None, build_model: str | None):
28
+ """Run a criterion twice (baseline vs. urgency/authority-pressure framing),
29
+ each in its own throwaway worktree, and compare advisory-check violation
30
+ counts. A widening gap under pressure is a real compliance-under-pressure
31
+ signal -- see pcp.pressure_test's docstring."""
32
+ try:
33
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
34
+ except NoPCPDir as e:
35
+ console.print(f"[red]Error:[/red] {e}")
36
+ sys.exit(2)
37
+
38
+ project_root = pcp_dir.parent
39
+ spec_path = pcp_dir / "strategy" / "modules" / module_name / "spec.yaml"
40
+ acc_path = pcp_dir / "strategy" / "modules" / module_name / "acceptance.yaml"
41
+ if not spec_path.exists() or not acc_path.exists():
42
+ console.print(f"[red]Error:[/red] module '{module_name}' not found under .pcp/strategy/modules/")
43
+ sys.exit(2)
44
+
45
+ spec = load_yaml(spec_path)
46
+ acc = load_yaml(acc_path)
47
+ criterion = next((c for c in acc.get("criteria", []) if c["id"] == criterion_id), None)
48
+ if criterion is None:
49
+ console.print(f"[red]Error:[/red] criterion '{criterion_id}' not found in module '{module_name}'")
50
+ sys.exit(2)
51
+
52
+ mod = {"name": module_name, "spec_path": spec_path, "acc_path": acc_path, "spec": spec}
53
+
54
+ console.print(
55
+ f"[bold]Pressure-testing[/bold] {module_name}/{criterion_id} -- "
56
+ "spawning 2 real coding-agent sessions (baseline + pressure framing), each in its own worktree..."
57
+ )
58
+ report = pressure_test.run_pressure_test(pcp_dir, project_root, mod, criterion, build_model=build_model)
59
+
60
+ console.print(f"\n[bold]Baseline advisory findings:[/bold] {report['baseline']['total_advisory']} {report['baseline']['advisory_counts']}")
61
+ console.print(f"[bold]Pressure advisory findings:[/bold] {report['pressure']['total_advisory']} {report['pressure']['advisory_counts']}")
62
+ if report["widened"]:
63
+ console.print(
64
+ f"[red bold]⚠ Compliance gap widened under pressure[/red bold] "
65
+ f"(+{report['delta']} advisory findings) -- this criterion showed MORE corner-cutting "
66
+ "when told to move fast. Worth a human look at what got skipped."
67
+ )
68
+ elif report["delta"] < 0:
69
+ console.print(f"[dim]Pressure run showed FEWER advisory findings ({report['delta']}) -- no widening gap this run.[/dim]")
70
+ else:
71
+ console.print("[green]No compliance gap under pressure this run.[/green]")
72
+ console.print(f"[dim]Logged to .pcp/pressure_test_log.jsonl[/dim]")
@@ -0,0 +1,313 @@
1
+ """pcp provenance — audit-evidence document: every file, every gate PCP has
2
+ approved, any skipped/bypassed step made visible.
3
+
4
+ Pure aggregation over already-persisted logs (.pcp/controls.yaml,
5
+ .pcp/telemetry.jsonl, .pcp/bypass_log.yaml) — no LLM call, no rebuild.
6
+ Callable at any point in a project's lifecycle, cheap, always a live
7
+ snapshot. This is the SLSA/in-toto-informed "quality card" half of the
8
+ CMMC/SSDF-style build-evidence document — controls.py's control catalog is
9
+ the other half.
10
+ """
11
+
12
+ import json
13
+ import subprocess
14
+ import sys
15
+ from collections import defaultdict
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
+
23
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
24
+ from pcp import telemetry as telemetry_mod
25
+ from pcp import decision_log as decision_log_mod
26
+ from pcp.evidence_chain import verify_chain
27
+
28
+ console = Console()
29
+
30
+ RESULT_SYMBOL = {
31
+ "pass": "OK", "block": "BLOCK", "skipped": "skip", "error": "ERROR", "bypassed": "bypass",
32
+ # "advisory" (2026-07-27): the check ran, found something, and deliberately
33
+ # did not block. Distinct from "pass" (found nothing) and "skipped" (never
34
+ # ran) — see _wave_record in build.py for why conflating it with "pass" was
35
+ # falsifying this very document.
36
+ "advisory": "ADVISORY",
37
+ }
38
+
39
+ # Fallback for telemetry records written before control_id existed (2026-07-01).
40
+ CHECK_TO_CONTROL = {
41
+ "test-suite": "CTRL-001",
42
+ "wave-test-suite": "CTRL-001",
43
+ "lint": "CTRL-002",
44
+ "sast": "CTRL-003",
45
+ "layer1": "CTRL-004",
46
+ "layer1-bypass": "CTRL-004",
47
+ "architect-review": "CTRL-005",
48
+ "wave-architect-review": "CTRL-005",
49
+ "gate": "CTRL-006",
50
+ "wave-contract": "CTRL-007",
51
+ "wave-validate-strategy": "CTRL-008",
52
+ "deploy-phase-exit": "CTRL-009",
53
+ "build-scope": "CTRL-018",
54
+ "tier-presence": "CTRL-019",
55
+ "rung-necessity": "CTRL-020",
56
+ }
57
+
58
+
59
+ def _load_controls(pcp_dir: Path) -> dict:
60
+ path = pcp_dir / "controls.yaml"
61
+ if not path.exists():
62
+ return {}
63
+ data = yaml.safe_load(path.read_text()) or {}
64
+ return {c["id"]: c for c in data.get("controls", [])}
65
+
66
+
67
+ def _load_bypasses(pcp_dir: Path) -> list[dict]:
68
+ path = pcp_dir / "bypass_log.yaml"
69
+ if not path.exists():
70
+ return []
71
+ data = yaml.safe_load(path.read_text()) or {}
72
+ return data.get("bypasses", [])
73
+
74
+
75
+ def _git_head(project_root: Path) -> str:
76
+ result = subprocess.run(
77
+ ["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=project_root,
78
+ )
79
+ return result.stdout.strip() if result.returncode == 0 else "unknown"
80
+
81
+
82
+ def _control_id_for(record: dict) -> str | None:
83
+ return record.get("control_id") or CHECK_TO_CONTROL.get(record.get("check"))
84
+
85
+
86
+ def _check_chain_integrity(pcp_dir: Path) -> dict:
87
+ """Verifies the hash chain on every append-only evidence log. Any non-empty
88
+ break list here means a record was edited, reordered, or deleted after
89
+ the fact — this is the one check in this whole document that isn't just
90
+ reporting what ran, it's reporting whether the report itself can be
91
+ trusted."""
92
+ telemetry_breaks = verify_chain(telemetry_mod.load(pcp_dir))
93
+ decision_breaks = verify_chain(decision_log_mod.load(pcp_dir))
94
+ bypass_breaks = verify_chain(_load_bypasses(pcp_dir))
95
+ return {
96
+ "telemetry.jsonl": telemetry_breaks,
97
+ "decision_log.jsonl": decision_breaks,
98
+ "bypass_log.yaml": bypass_breaks,
99
+ }
100
+
101
+
102
+ def build_provenance(pcp_dir: Path) -> dict:
103
+ """Pure aggregation — safe to call at any point, no side effects besides read."""
104
+ controls = _load_controls(pcp_dir)
105
+ # Not just cycle=="qa" -- cross-cutting controls (e.g. CTRL-011 transcript
106
+ # archival, recorded under cycle="capture") carry a control_id too and
107
+ # belong in the same per-file/per-control coverage view.
108
+ qa_records = [r for r in telemetry_mod.load(pcp_dir) if _control_id_for(r)]
109
+ bypasses = _load_bypasses(pcp_dir)
110
+ chain_integrity = _check_chain_integrity(pcp_dir)
111
+
112
+ per_file: dict[str, dict[str, dict]] = defaultdict(dict)
113
+ per_control: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
114
+
115
+ for r in qa_records:
116
+ cid = _control_id_for(r)
117
+ if not cid:
118
+ continue
119
+ result = r.get("result", "pass")
120
+ per_control[cid]["total"] += 1
121
+ per_control[cid][result] += 1
122
+ for f in (r.get("files") or []):
123
+ existing = per_file[f].get(cid)
124
+ if not existing or r.get("timestamp", "") >= existing.get("timestamp", ""):
125
+ per_file[f][cid] = r
126
+
127
+ # A control whose every recorded outcome is "skipped" never actually ran
128
+ # anywhere in this project — a project-wide blind spot, not per-file noise.
129
+ standing_gap_cids = {
130
+ cid for cid, totals in per_control.items()
131
+ if totals["total"] > 0 and totals.get("skipped", 0) == totals["total"]
132
+ }
133
+ never_exercised_cids = {
134
+ cid for cid in controls if cid not in per_control and cid != "CTRL-010"
135
+ }
136
+
137
+ return {
138
+ "controls": controls,
139
+ "per_file": {f: dict(v) for f, v in per_file.items()},
140
+ "per_control": {cid: dict(v) for cid, v in per_control.items()},
141
+ "standing_gap_cids": sorted(standing_gap_cids),
142
+ "never_exercised_cids": sorted(never_exercised_cids),
143
+ "bypasses": bypasses,
144
+ "chain_integrity": chain_integrity,
145
+ }
146
+
147
+
148
+ def _render_markdown(project_root: Path, data: dict, timestamp: str) -> str:
149
+ controls = data["controls"]
150
+ per_file = data["per_file"]
151
+ per_control = data["per_control"]
152
+
153
+ lines = [
154
+ "# PCP Audit Evidence",
155
+ f"Generated: {timestamp} | Git: `{_git_head(project_root)}`",
156
+ "",
157
+ "> Auto-generated by `pcp provenance`. Aggregates `.pcp/controls.yaml` + "
158
+ "`.pcp/telemetry.jsonl` + `.pcp/bypass_log.yaml`. Do not edit manually. "
159
+ "SSDF mapping is informed, not a certified assessment.",
160
+ "",
161
+ "## Per-File Gate Coverage",
162
+ "",
163
+ ]
164
+
165
+ if not per_file:
166
+ lines += ["_No telemetry recorded yet — run `pcp build` first._", ""]
167
+ else:
168
+ control_ids = sorted(controls.keys()) or sorted({cid for f in per_file.values() for cid in f})
169
+ lines.append("| File | " + " | ".join(control_ids) + " |")
170
+ lines.append("|---|" + "---|" * len(control_ids))
171
+ for f in sorted(per_file.keys()):
172
+ row = [f"`{f}`"]
173
+ for cid in control_ids:
174
+ rec = per_file[f].get(cid)
175
+ row.append("-" if not rec else RESULT_SYMBOL.get(rec.get("result"), rec.get("result", "?")))
176
+ lines.append("| " + " | ".join(row) + " |")
177
+ lines.append("")
178
+ legend = ", ".join(f"`{cid}`={controls.get(cid, {}).get('name', cid)}" for cid in control_ids)
179
+ lines += [f"Legend: {legend}", ""]
180
+
181
+ lines += ["## Standing Gaps (control never actually ran project-wide)", ""]
182
+ gap_lines = []
183
+ for cid in data["standing_gap_cids"]:
184
+ name = controls.get(cid, {}).get("name", cid)
185
+ total = per_control.get(cid, {}).get("total", 0)
186
+ gap_lines.append(f"- {cid} ({name}): {total}/{total} checks skipped — tool never detected project-wide.")
187
+ for cid in data["never_exercised_cids"]:
188
+ name = controls.get(cid, {}).get("name", cid)
189
+ gap_lines.append(f"- {cid} ({name}): never invoked in this project yet.")
190
+ lines += gap_lines if gap_lines else ["_None — every cataloged control has run with a real tool at least once._"]
191
+ lines.append("")
192
+
193
+ lines += ["## Bypass Ledger", ""]
194
+ if data["bypasses"]:
195
+ for b in data["bypasses"]:
196
+ rules = ", ".join(b.get("rules_bypassed", []))
197
+ lines.append(f"- `{b.get('timestamp', '')}` — {b.get('reason', '')} (rules: {rules})")
198
+ else:
199
+ lines.append("_No bypasses._")
200
+ lines.append("")
201
+
202
+ # Escalation responsiveness — proves the escalation ledger is watched,
203
+ # not just written (PagerDuty Escalation Policy Insights pattern).
204
+ from pcp import escalations as _esc
205
+ _esc_dir = project_root / ".pcp"
206
+ mtta = _esc.mtta_hours(_esc_dir)
207
+ esc_entries = _esc.load(_esc_dir)
208
+ lines += ["## Escalation Responsiveness", ""]
209
+ if esc_entries:
210
+ acked = sum(1 for e in esc_entries if e.get("acknowledged_at"))
211
+ lines.append(f"- Escalations recorded: {len(esc_entries)}; acknowledged: {acked}")
212
+ lines.append(f"- MTTA (median time-to-acknowledge): {mtta}h" if mtta is not None
213
+ else "- MTTA: no escalation has ever been acknowledged — ledger may be unwatched")
214
+ else:
215
+ lines.append("_No escalations recorded._")
216
+ lines.append("")
217
+
218
+ # Auditability Card (adapted from "Auditable Agents", arXiv:2604.05485 —
219
+ # its 5-dimension framework maps 1:1 onto PCP's mechanisms). Deterministic
220
+ # self-score from artifacts that exist, not aspiration.
221
+ _pcp = project_root / ".pcp"
222
+ card = [
223
+ ("Action recoverability", (_pcp / "telemetry.jsonl").exists() and (_pcp / "evidence").exists(),
224
+ "per-attempt telemetry + raw untruncated QA evidence"),
225
+ ("Lifecycle coverage", (_pcp / "controls.yaml").exists() and (_pcp / "deploy_log.yaml").exists(),
226
+ "controls span commit->PR->wave->deploy; deploy log present"),
227
+ ("Policy checkability", (_pcp / "ci_rules.yaml").exists() and (_pcp / "policies").exists(),
228
+ "machine-checkable rules (ci_rules.yaml) + human-editable OPA policies"),
229
+ ("Responsibility attribution", (_pcp / "bypass_log.yaml").exists() or (_pcp / "escalations.yaml").exists(),
230
+ "attributed bypasses and/or escalation ledger with ack states"),
231
+ ("Evidence integrity", bool(data.get("chain_integrity")),
232
+ "hash-chained logs, verified each provenance run (unsigned — see roadmap)"),
233
+ ]
234
+ lines += ["## Auditability Card", "",
235
+ "Five dimensions from the 'Auditable Agents' framework (arXiv:2604.05485), "
236
+ "scored from artifacts actually present in this project:", "",
237
+ "| Dimension | Present | Basis |", "|---|---|---|"]
238
+ for name, ok, basis in card:
239
+ lines.append(f"| {name} | {'✓' if ok else '✗'} | {basis} |")
240
+ lines.append("")
241
+
242
+ lines += ["## Chain Integrity", "", "Each evidence log is hash-chained — an entry's hash covers its own "
243
+ "content plus the previous entry's hash, so an edit/reorder/deletion after the fact is "
244
+ "detectable even though the files themselves are plain, editable JSON/YAML.", ""]
245
+ any_break = False
246
+ for log_name, breaks in data["chain_integrity"].items():
247
+ if not breaks:
248
+ lines.append(f"- `{log_name}`: intact.")
249
+ else:
250
+ any_break = True
251
+ lines.append(f"- `{log_name}`: **{len(breaks)} break(s) detected**")
252
+ for b in breaks:
253
+ lines.append(f" - index {b['index']}: {b['issue']}")
254
+ if any_break:
255
+ lines.append("")
256
+ lines.append("**A break here means this evidence document cannot be trusted as-is — "
257
+ "investigate before relying on anything above.**")
258
+ lines.append("")
259
+
260
+ lines += ["## SSDF Crosswalk", "", "| Control | SSDF Practice | Enforcement | Status |", "|---|---|---|---|"]
261
+ for cid, c in sorted(controls.items()):
262
+ totals = per_control.get(cid, {})
263
+ if cid == "CTRL-010":
264
+ status = f"{len(data['bypasses'])} bypass(es) logged" if data["bypasses"] else "0 bypasses"
265
+ elif cid in data["never_exercised_cids"]:
266
+ status = "GAP — never invoked"
267
+ elif cid in data["standing_gap_cids"]:
268
+ status = "GAP — tool never detected"
269
+ else:
270
+ status = f"{totals.get('pass', 0)}/{totals.get('total', 0)} pass"
271
+ practices = ", ".join(c.get("ssdf_practice", []))
272
+ lines.append(f"| {cid} {c.get('name', '')} | {practices} | {c.get('enforcement', '')} | {status} |")
273
+ lines.append("")
274
+
275
+ return "\n".join(lines)
276
+
277
+
278
+ def write_provenance(pcp_dir: Path) -> Path:
279
+ data = build_provenance(pcp_dir)
280
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
281
+ md = _render_markdown(pcp_dir.parent, data, timestamp)
282
+ out = pcp_dir / "provenance.md"
283
+ out.write_text(md)
284
+ return out
285
+
286
+
287
+ @click.command()
288
+ @click.option("--path", "project_path", type=click.Path(), default=None)
289
+ @click.option("--json", "output_json", is_flag=True, help="Print raw JSON instead of writing provenance.md.")
290
+ @click.option("--attest", "attest_flag", is_flag=True,
291
+ help="Also export in-toto Statement v1 / DSSE envelopes to .pcp/attestations.jsonl "
292
+ "(unsigned unless --sign and cosign present).")
293
+ @click.option("--sign", "sign_flag", is_flag=True,
294
+ help="Sign the attestation export via cosign sign-blob (requires cosign; interactive OIDC).")
295
+ def provenance(project_path: str | None, output_json: bool, attest_flag: bool, sign_flag: bool):
296
+ """Audit-evidence document — every file, every gate, any skip/bypass made visible."""
297
+ try:
298
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
299
+ except NoPCPDir as e:
300
+ console.print(f"[red]Error:[/red] {e}")
301
+ sys.exit(2)
302
+
303
+ if attest_flag or sign_flag:
304
+ from pcp.attest import export_attestations
305
+ out, count, signing_note = export_attestations(pcp_dir, sign=sign_flag)
306
+ console.print(f"[green]✓[/green] {count} in-toto attestation(s) -> {out.name} [{signing_note}]")
307
+
308
+ if output_json:
309
+ click.echo(json.dumps(build_provenance(pcp_dir), indent=2, default=str))
310
+ return
311
+
312
+ out_path = write_provenance(pcp_dir)
313
+ console.print(f"[green]Provenance written[/green] -> {out_path.relative_to(pcp_dir.parent)}")
pcp/commands/prune.py ADDED
@@ -0,0 +1,179 @@
1
+ """pcp prune — retention/pruning for .pcp/evidence/ and .pcp/transcripts/.
2
+
3
+ Both grow unboundedly (CLAUDE.md names this explicitly: full raw QA proof
4
+ on every check, gzip-archived session transcripts on every pcp capture
5
+ call, no retention policy). Deliberately scoped to these two directories
6
+ only, NOT telemetry.jsonl/decision_log.jsonl/bypass_log.yaml -- those are
7
+ hash-chained (evidence_chain.py) and pruning entries out of the front of a
8
+ hash chain breaks verify_chain() at the new first record unless the
9
+ pruning routine re-anchors the chain with an explicit checkpoint, a bigger
10
+ change than this pass takes on. evidence/transcripts are plain files with
11
+ no chain to preserve, so they're the safe, high-value target: the actual
12
+ disk-bloat culprits (full test-suite output, full judge responses, full
13
+ session transcripts) rather than telemetry.jsonl's small per-record JSON
14
+ lines.
15
+
16
+ Deletion, not archival -- an irreversible action, so it gets the same
17
+ posture pcp deploy already established as this project's one other
18
+ genuinely irreversible action: mandatory interactive confirmation by
19
+ default, --yes opts out.
20
+
21
+ A deleted evidence file leaves a dangling evidence_path reference in
22
+ telemetry.jsonl -- dashboard.py already just renders that as a link
23
+ without checking existence, a known pre-existing gap this doesn't
24
+ introduce or fix, just doesn't make worse.
25
+ """
26
+
27
+ import shutil
28
+ import sys
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ import click
33
+ import yaml
34
+ from rich.console import Console
35
+
36
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
37
+ from pcp.schema.validator import load_yaml
38
+
39
+ console = Console()
40
+
41
+ PRUNE_LOG = "prune_log.yaml"
42
+
43
+
44
+ def _retention_config(pcp_dir: Path) -> dict:
45
+ ci_rules_path = pcp_dir / "ci_rules.yaml"
46
+ if not ci_rules_path.exists():
47
+ return {}
48
+ return (load_yaml(ci_rules_path) or {}).get("retention") or {}
49
+
50
+
51
+ def _age_days(path: Path, now: datetime) -> float:
52
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
53
+ return (now - mtime).total_seconds() / 86400
54
+
55
+
56
+ def _find_stale_evidence(pcp_dir: Path, evidence_days: int, now: datetime) -> list[Path]:
57
+ evidence_dir = pcp_dir / "evidence"
58
+ if not evidence_dir.exists():
59
+ return []
60
+ return [
61
+ p for p in evidence_dir.rglob("*.txt")
62
+ if p.is_file() and _age_days(p, now) > evidence_days
63
+ ]
64
+
65
+
66
+ def _find_stale_transcripts(pcp_dir: Path, transcript_days: int, now: datetime) -> list[Path]:
67
+ transcripts_dir = pcp_dir / "transcripts"
68
+ if not transcripts_dir.exists():
69
+ return []
70
+ return [
71
+ p for p in transcripts_dir.glob("*.jsonl.gz")
72
+ if p.is_file() and _age_days(p, now) > transcript_days
73
+ ]
74
+
75
+
76
+ def _log_prune_run(pcp_dir: Path, evidence_days: int | None, transcript_days: int | None,
77
+ files_removed: int, bytes_reclaimed: int) -> None:
78
+ """Plain append list, not hash-chained -- this is an operational
79
+ maintenance record (what a config-driven cleanup pass did), not a
80
+ security/audit control checkpoint the way telemetry.jsonl/bypass_log.yaml
81
+ are. Scope kept narrow deliberately, same "phase 1, not the finished
82
+ thing" posture as pcp docs's drift score."""
83
+ log_path = pcp_dir / PRUNE_LOG
84
+ existing = []
85
+ if log_path.exists():
86
+ existing = (yaml.safe_load(log_path.read_text()) or {}).get("runs", [])
87
+ existing.append({
88
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
89
+ "evidence_days": evidence_days,
90
+ "transcript_days": transcript_days,
91
+ "files_removed": files_removed,
92
+ "bytes_reclaimed": bytes_reclaimed,
93
+ })
94
+ log_path.write_text(yaml.dump({"runs": existing}, default_flow_style=False))
95
+
96
+
97
+ def run_prune(pcp_dir: Path, evidence_days: int | None, transcript_days: int | None,
98
+ now: datetime | None = None) -> dict:
99
+ """Pure discovery + deletion, no prompting -- the CLI command owns the
100
+ confirmation step. Returns a summary dict; always safe to call with
101
+ both days=None (finds nothing, deletes nothing)."""
102
+ now = now or datetime.now(timezone.utc)
103
+ stale_evidence = _find_stale_evidence(pcp_dir, evidence_days, now) if evidence_days else []
104
+ stale_transcripts = _find_stale_transcripts(pcp_dir, transcript_days, now) if transcript_days else []
105
+ all_stale = stale_evidence + stale_transcripts
106
+ total_bytes = sum(p.stat().st_size for p in all_stale)
107
+
108
+ return {
109
+ "evidence_files": stale_evidence,
110
+ "transcript_files": stale_transcripts,
111
+ "total_files": len(all_stale),
112
+ "total_bytes": total_bytes,
113
+ }
114
+
115
+
116
+ def _delete_files(paths: list[Path]) -> None:
117
+ for p in paths:
118
+ p.unlink(missing_ok=True)
119
+
120
+
121
+ @click.command()
122
+ @click.option("--path", "project_path", type=click.Path(), default=None,
123
+ help="Project root (default: cwd, walks up to find .pcp/).")
124
+ @click.option("--evidence-days", type=int, default=None,
125
+ help="Delete .pcp/evidence/*.txt older than N days (overrides ci_rules.yaml's retention.evidence_days).")
126
+ @click.option("--transcript-days", type=int, default=None,
127
+ help="Delete .pcp/transcripts/*.jsonl.gz older than N days (overrides ci_rules.yaml's retention.transcript_days).")
128
+ @click.option("--dry-run", is_flag=True, help="Show what would be deleted, delete nothing.")
129
+ @click.option("--yes", is_flag=True, help="Skip the interactive confirmation prompt (CI/non-interactive use).")
130
+ def prune(project_path: str | None, evidence_days: int | None, transcript_days: int | None,
131
+ dry_run: bool, yes: bool):
132
+ """Delete stale raw QA evidence / session transcripts past a retention window.
133
+
134
+ No retention happens unless configured -- via --evidence-days/
135
+ --transcript-days here, or a `retention:` block in ci_rules.yaml. A bare
136
+ `pcp prune` with nothing configured does nothing and says so."""
137
+ try:
138
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
139
+ except NoPCPDir as e:
140
+ console.print(f"[red]Error:[/red] {e}")
141
+ sys.exit(2)
142
+
143
+ config = _retention_config(pcp_dir)
144
+ evidence_days = evidence_days if evidence_days is not None else config.get("evidence_days")
145
+ transcript_days = transcript_days if transcript_days is not None else config.get("transcript_days")
146
+
147
+ if not evidence_days and not transcript_days:
148
+ console.print(
149
+ "[dim]No retention configured -- nothing to do. Pass --evidence-days/--transcript-days, "
150
+ "or add a `retention:` block to .pcp/ci_rules.yaml.[/dim]"
151
+ )
152
+ sys.exit(0)
153
+
154
+ result = run_prune(pcp_dir, evidence_days, transcript_days)
155
+ if result["total_files"] == 0:
156
+ console.print("[green]Nothing past the retention window.[/green]")
157
+ sys.exit(0)
158
+
159
+ mb = result["total_bytes"] / (1024 * 1024)
160
+ console.print(
161
+ f"[bold]{result['total_files']} file(s)[/bold] past retention "
162
+ f"({len(result['evidence_files'])} evidence, {len(result['transcript_files'])} transcripts), "
163
+ f"~{mb:.1f} MB."
164
+ )
165
+
166
+ if dry_run:
167
+ console.print("[dim]--dry-run: nothing deleted.[/dim]")
168
+ sys.exit(0)
169
+
170
+ if not yes:
171
+ prompt = f"Permanently delete these {result['total_files']} file(s)?"
172
+ if not click.confirm(prompt, default=False):
173
+ console.print("[yellow]Prune aborted.[/yellow]")
174
+ sys.exit(0)
175
+
176
+ _delete_files(result["evidence_files"])
177
+ _delete_files(result["transcript_files"])
178
+ _log_prune_run(pcp_dir, evidence_days, transcript_days, result["total_files"], result["total_bytes"])
179
+ console.print(f"[green]✓[/green] Deleted {result['total_files']} file(s), reclaimed ~{mb:.1f} MB.")
pcp/commands/report.py ADDED
@@ -0,0 +1,49 @@
1
+ """pcp report — DEPRECATED (2026-07-21).
2
+
3
+ Confirmed a genuine duplicate, not a distinct capability: this command had
4
+ its own separate implementation reading bypass_log.yaml/current_state.md
5
+ directly, never calling into provenance.py's build_provenance(). Every
6
+ data point it showed already exists elsewhere -- the bypass ledger is a
7
+ full section of `pcp provenance`'s output; the coverage percentage is
8
+ already in current_state.md itself (which this command only re-read) and
9
+ surfaced again in pcp.md/dashboard.html. Zero unique data, duplicate code
10
+ instead of reuse -- exactly the catalog-bloat pattern PCP's own
11
+ self-evaluation named. Kept as a command (not removed outright) so a
12
+ script or muscle-memory invocation gets a clear redirect instead of a
13
+ "command not found" error.
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import click
21
+ from rich.console import Console
22
+
23
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
24
+
25
+ console = Console()
26
+
27
+ _DEPRECATION_MESSAGE = (
28
+ "`pcp report` is deprecated -- use `pcp provenance` (bypass ledger + SSDF crosswalk) "
29
+ "or `pcp dashboard` (Audit Trail tab) instead. Both already show everything this "
30
+ "command did, with more context."
31
+ )
32
+
33
+
34
+ @click.command()
35
+ @click.option("--path", "project_path", type=click.Path(), default=None)
36
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
37
+ def report(project_path: str | None, output_json: bool):
38
+ """DEPRECATED -- use `pcp provenance` or `pcp dashboard` instead."""
39
+ try:
40
+ find_pcp_dir(Path(project_path) if project_path else None)
41
+ except NoPCPDir as e:
42
+ console.print(f"[red]Error:[/red] {e}")
43
+ sys.exit(2)
44
+
45
+ if output_json:
46
+ click.echo(json.dumps({"deprecated": True, "use_instead": ["pcp provenance", "pcp dashboard"]}, indent=2))
47
+ return
48
+
49
+ console.print(f"[yellow]{_DEPRECATION_MESSAGE}[/yellow]")