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/qa.py ADDED
@@ -0,0 +1,445 @@
1
+ """QA gates — test suite, lint, SAST/secret-scan.
2
+
3
+ Wraps whatever's already installed for the project's language, same philosophy
4
+ as audit.py's dead-code wrapper: detect a real tool, shell out to it, skip
5
+ gracefully (never block) if nothing's installed. Called from pcp build's
6
+ per-criterion loop, per docs/greenfield.md Phase 3.
7
+
8
+ Honest scope: this verifies "tests pass and lint/SAST is clean before the
9
+ criterion is accepted" — a real, checkable contract. It does NOT enforce
10
+ RED-then-GREEN ordering (test written and failing before code exists) — that
11
+ would require snapshotting agent state mid-session, a different and bigger
12
+ feature than what's built here.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ # Real incident, 2026-07-18 (Project O dogfood): these were bare
23
+ # module constants with no env override -- a real project's test suite
24
+ # legitimately taking 420-550s against a real Postgres backend always got
25
+ # falsely marked "timed out" against the 300s default, burning a build
26
+ # retry every time. A local hand-patch to the constant had nowhere durable
27
+ # to live and got silently wiped on every reinstall/reset. Same env-override
28
+ # pattern as llm/client.py's _timeout() (PCP_LLM_TIMEOUT) and
29
+ # PCP_BUILD_AGENT_TIMEOUT_SEC -- read lazily so a test can monkeypatch the
30
+ # env var and see the effect without reimporting this module.
31
+ def _timeout_test() -> int:
32
+ return int(os.environ.get("PCP_QA_TEST_TIMEOUT_SEC", "300"))
33
+
34
+
35
+ def test_timeout_info() -> tuple[int, bool]:
36
+ """(effective timeout seconds, True if PCP_QA_TEST_TIMEOUT_SEC is unset
37
+ and the 300s default is silently in effect) -- lets a build run print
38
+ this loud instead of a wrong-environment failure (e.g. the DB the tests
39
+ hit isn't the one intended) surfacing as an indistinguishable "timed
40
+ out", same masking this module's own docstring above already documents
41
+ for the slow-suite case."""
42
+ return _timeout_test(), "PCP_QA_TEST_TIMEOUT_SEC" not in os.environ
43
+
44
+
45
+ def _partial_output(exc: subprocess.TimeoutExpired) -> str:
46
+ """Whatever the killed process had already written before the timeout.
47
+
48
+ `TimeoutExpired` carries the partial `stdout`/`stderr` captured up to the
49
+ kill, and every handler here discarded it -- the exception wasn't even bound
50
+ to a name. Measured on Project O 2026-07-30: a 900s pytest timeout
51
+ produced a **311-byte** evidence file containing nothing but PCP's own
52
+ advice message. Twelve of that run's nineteen test-gate blocks were
53
+ timeouts, so twelve full retries were spent, ~$37 of a $78 run, and a human
54
+ opening the evidence could not tell which test hung.
55
+
56
+ The message below is good prose about what to DO and says nothing about what
57
+ HAPPENED. pytest's own partial output names the last test it started, which
58
+ is the one thing that identifies the hang."""
59
+ parts = []
60
+ for label, stream in (("stdout", exc.stdout), ("stderr", exc.stderr)):
61
+ if not stream:
62
+ continue
63
+ text = stream.decode("utf-8", "replace") if isinstance(stream, bytes) else str(stream)
64
+ text = text.strip()
65
+ if text:
66
+ parts.append(f"--- partial {label} before the kill (last 4000 chars) ---\n{text[-4000:]}")
67
+ if not parts:
68
+ return (
69
+ "\n\n--- no partial output was captured before the kill ---\n"
70
+ "The process produced nothing on stdout/stderr, which itself is a signal: it "
71
+ "hung before writing even a collection header, so suspect a blocking connection "
72
+ "at import/fixture time rather than a slow test."
73
+ )
74
+ return "\n\n" + "\n\n".join(parts)
75
+
76
+
77
+ def _timeout_message(tool: str, exc: subprocess.TimeoutExpired | None = None) -> str:
78
+ """A bare "timed out" in the evidence file reads as "the test suite failed"
79
+ to whoever opens it next -- it names neither the limit that was hit nor the
80
+ knob that changes it, so the natural next move is to go debug tests that
81
+ may well be fine. Say which limit, and say the default is a default.
82
+
83
+ Also append whatever the process actually managed to say -- see
84
+ _partial_output for why advice-without-data was not enough.
85
+
86
+ The limit is read off the exception when one is given. `test_timeout_info()`
87
+ reports the limit in effect *now*, which is not necessarily the one that
88
+ killed this process -- `exc.timeout` is the value that actually applied."""
89
+ seconds, is_default = test_timeout_info()
90
+ if exc is not None and exc.timeout:
91
+ actual = int(exc.timeout)
92
+ is_default = is_default and actual == seconds
93
+ seconds = actual
94
+ suffix = " (PCP default — not tuned for this project)" if is_default else ""
95
+ msg = (
96
+ f"{tool} exceeded the {seconds}s PCP_QA_TEST_TIMEOUT_SEC limit{suffix} and was killed. "
97
+ f"No test result was produced — this is NOT a test failure. Either the suite genuinely "
98
+ f"needs longer (raise PCP_QA_TEST_TIMEOUT_SEC) or it is blocked on something that never "
99
+ f"returns (a hung DB connection, a wrong-environment target, a lock)."
100
+ )
101
+ return msg + (_partial_output(exc) if exc is not None else "")
102
+
103
+
104
+ def _timeout_lint() -> int:
105
+ return int(os.environ.get("PCP_QA_LINT_TIMEOUT_SEC", "60"))
106
+
107
+
108
+ def _timeout_sast() -> int:
109
+ return int(os.environ.get("PCP_QA_SAST_TIMEOUT_SEC", "120"))
110
+
111
+
112
+ def project_tool(project_root: Path, name: str) -> str | None:
113
+ """Resolve a dev tool from the PROJECT's virtualenv before falling back to PATH.
114
+
115
+ PCP shelled out to a bare `pytest`, which resolves from PATH. A user who
116
+ runs `pcp build` without activating the project's venv therefore had their
117
+ project tested by whatever pytest happened to be global -- a different
118
+ interpreter, different installed packages, and results that describe some
119
+ other environment. It stayed invisible because activating the venv is the
120
+ normal habit; nothing failed loudly when it wasn't.
121
+
122
+ That is the same shape as two other bugs found on 2026-07-27: the Postgres
123
+ check counting schemas on whatever answered port 5432 (a shadow container,
124
+ for four days), and `pcp --version` reporting metadata while the code was
125
+ something else. A check aimed confidently at the wrong target reads exactly
126
+ like a passing check.
127
+
128
+ Order: the project's own venv, then PATH. `PCP_TOOL_FROM_PATH=1` forces the
129
+ old behaviour for projects that deliberately use globally-installed tools.
130
+ """
131
+ if os.environ.get("PCP_TOOL_FROM_PATH") != "1":
132
+ for venv in (".venv", "venv", "env"):
133
+ for sub in ("bin", "Scripts"):
134
+ candidate = project_root / venv / sub / name
135
+ if candidate.is_file() and os.access(candidate, os.X_OK):
136
+ return str(candidate)
137
+ exe = candidate.with_suffix(".exe")
138
+ if exe.is_file():
139
+ return str(exe)
140
+ return shutil.which(name)
141
+
142
+
143
+ def testmon_available(project_root: Path) -> bool:
144
+ """Is pytest-testmon installed in the environment pytest will run from?
145
+
146
+ Detected, never depended on -- the same contract this module already has
147
+ with ruff and semgrep, so PCP neither vendors it nor inherits its
148
+ packaging. Checked by asking pytest itself rather than importing testmon
149
+ here, because the interpreter running PCP is frequently not the one running
150
+ the project's tests (PCP is commonly installed globally while the project
151
+ has its own venv).
152
+
153
+ PCP_QA_NO_TESTMON=1 turns it off.
154
+ """
155
+ if os.environ.get("PCP_QA_NO_TESTMON") == "1":
156
+ return False
157
+ pytest_bin = project_tool(project_root, "pytest")
158
+ if not pytest_bin:
159
+ return False
160
+ try:
161
+ r = subprocess.run([pytest_bin, "--help"], capture_output=True, text=True,
162
+ cwd=project_root, timeout=30)
163
+ except (OSError, subprocess.SubprocessError):
164
+ return False
165
+ return "--testmon" in (r.stdout + r.stderr)
166
+
167
+
168
+ def _run_pytest(project_root: Path, test_paths: list[str] | None = None,
169
+ incremental: bool = False) -> dict | None:
170
+ """Run pytest, optionally letting testmon skip tests it knows are unaffected.
171
+
172
+ Path scoping (impact.py) reduces BREADTH -- which tests are eligible. It
173
+ does nothing about REPETITION, which is the larger term: between one
174
+ criterion and the next a handful of files change and nearly every eligible
175
+ test re-executes against identical code, up to 3 attempts per criterion.
176
+ Measured 2026-07-27 on Project O, scoping alone still left an
177
+ average of 37% of a 1,279-test suite per run, and 99% for the hub module
178
+ every other module depends on.
179
+
180
+ testmon tracks per-test dependencies from actual coverage, so it also fixes
181
+ the two cases scoping structurally cannot: a hub module whose declared
182
+ blast radius really is the whole project, and a greenfield module with no
183
+ source yet to attribute changed files to. It derives dependencies from
184
+ execution rather than from the declared module graph -- which this project's
185
+ own telemetry showed to be fiction (`standards_interop` broke in 69% of
186
+ blocking runs while nothing declared a dependency on it).
187
+
188
+ `incremental` is only ever set for the per-criterion gate. The wave-merge
189
+ gate deliberately runs the full suite with NO testmon: an incremental
190
+ runner is a cache, and the wave boundary is exactly where a cache should be
191
+ distrusted and the real answer computed.
192
+ """
193
+ pytest_bin = project_tool(project_root, "pytest")
194
+ if not pytest_bin:
195
+ return None
196
+ use_testmon = incremental and testmon_available(project_root)
197
+ args = [pytest_bin, "-q"] + (["--testmon"] if use_testmon else []) + list(test_paths or [])
198
+ try:
199
+ result = subprocess.run(
200
+ args, capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
201
+ )
202
+ except subprocess.TimeoutExpired as e:
203
+ return {"tool": "pytest", "passed": False, "output": _timeout_message("pytest", e)}
204
+
205
+ # Exit code 5 = no tests collected. Under testmon that is the SUCCESS case
206
+ # -- "nothing this change touches" -- and it is also what a broken testmon
207
+ # database looks like. Re-run without it rather than reporting a pass on
208
+ # zero tests from a cache PCP cannot verify.
209
+ if use_testmon and result.returncode not in (0, 1):
210
+ plain = subprocess.run(
211
+ [pytest_bin, "-q", *(test_paths or [])],
212
+ capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
213
+ )
214
+ return {
215
+ "tool": "pytest", "passed": plain.returncode in (0, 5),
216
+ "output": (plain.stdout + plain.stderr)[-3000:],
217
+ "scoped_to": test_paths or None, "incremental": False,
218
+ "pytest_bin": pytest_bin,
219
+ "testmon_fallback": f"testmon exited {result.returncode}; re-ran full scope",
220
+ }
221
+
222
+ passed = result.returncode == 0 or result.returncode == 5
223
+ return {
224
+ "tool": "pytest", "passed": passed, "output": (result.stdout + result.stderr)[-3000:],
225
+ "scoped_to": test_paths or None, "incremental": use_testmon,
226
+ "pytest_bin": pytest_bin,
227
+ }
228
+
229
+
230
+ def _run_npm_test(project_root: Path) -> dict | None:
231
+ pkg = project_root / "package.json"
232
+ if not pkg.exists() or not shutil.which("npm"):
233
+ return None
234
+ try:
235
+ data = json.loads(pkg.read_text())
236
+ except Exception:
237
+ return None
238
+ if "test" not in data.get("scripts", {}):
239
+ return None
240
+ try:
241
+ result = subprocess.run(
242
+ ["npm", "test", "--silent"], capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
243
+ )
244
+ except subprocess.TimeoutExpired as e:
245
+ return {"tool": "npm test", "passed": False, "output": _timeout_message("npm test", e)}
246
+ return {"tool": "npm test", "passed": result.returncode == 0, "output": (result.stdout + result.stderr)[-3000:]}
247
+
248
+
249
+ def _run_go_test(project_root: Path) -> dict | None:
250
+ if not (project_root / "go.mod").exists() or not shutil.which("go"):
251
+ return None
252
+ try:
253
+ result = subprocess.run(
254
+ ["go", "test", "./..."], capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
255
+ )
256
+ except subprocess.TimeoutExpired as e:
257
+ return {"tool": "go test", "passed": False, "output": _timeout_message("go test", e)}
258
+ return {"tool": "go test", "passed": result.returncode == 0, "output": (result.stdout + result.stderr)[-3000:]}
259
+
260
+
261
+ def full_suite_forced() -> bool:
262
+ """Escape hatch, not a feature flag.
263
+
264
+ Impact scoping used to be opt-in behind PCP_QA_TEST_SELECTION=impact, off
265
+ by default, while `_run_test_suite_check`'s own docstring already described
266
+ scoped-per-criterion + full-suite-at-wave-merge as the design. The design
267
+ was documented and disabled, so every criterion attempt ran the entire
268
+ suite -- 1,098 tests, ~7m46s, on Project O, re-running the same
269
+ tests up to three times per criterion.
270
+
271
+ Scoping is now the norm. PCP_QA_FULL_SUITE=1 forces the old behaviour for
272
+ anyone who wants it back.
273
+ """
274
+ return os.environ.get("PCP_QA_FULL_SUITE") == "1"
275
+
276
+
277
+ def run_test_suite(project_root: Path, pcp_dir: Path | None = None, changed_files: list[str] | None = None) -> dict:
278
+ """Scoped to the blast radius of the change when that can be determined,
279
+ full regression suite otherwise.
280
+
281
+ Given pcp_dir/changed_files, scopes pytest to the modules impacted by the
282
+ change plus the modularity drop-tests (see impact.py:
283
+ the changed module(s) plus every module that transitively depends on
284
+ them, via the same dependency graph coupling.py already trusts for
285
+ coupling_score — not a separate ad hoc mechanism). Only pytest
286
+ supports scoping today; npm/go test always run in full, an honest scope
287
+ limit, not a silent gap. Falls back to the full suite whenever impact.py
288
+ can't confidently narrow the scope — never silently runs zero tests.
289
+
290
+ The unscoped full suite is the WAVE-MERGE gate's job (see
291
+ _run_wave_merge_gate), which is where cross-module regression risk is
292
+ genuinely resolved — not on every one of up to three attempts per
293
+ criterion. PCP_QA_FULL_SUITE=1 restores the old always-full behaviour."""
294
+ test_paths = None
295
+ if not full_suite_forced() and pcp_dir is not None and changed_files:
296
+ from pcp.impact import blast_radius_test_paths
297
+ try:
298
+ test_paths = blast_radius_test_paths(pcp_dir, project_root, changed_files)
299
+ except Exception:
300
+ test_paths = None # scoping failed -- fall back to the full suite, don't propagate
301
+
302
+ if test_paths and project_tool(project_root, "pytest"):
303
+ out = _run_pytest(project_root, test_paths, incremental=True)
304
+ if out is not None:
305
+ return out
306
+
307
+ for runner in (_run_pytest, _run_npm_test, _run_go_test):
308
+ out = runner(project_root)
309
+ if out is not None:
310
+ return out
311
+ return {"tool": None, "passed": True, "output": ""}
312
+
313
+
314
+ def _run_ruff(project_root: Path, changed_files: list[str]) -> dict | None:
315
+ ruff_bin = project_tool(project_root, "ruff")
316
+ if not ruff_bin:
317
+ return None
318
+ py_files = [f for f in changed_files if f.endswith(".py")]
319
+ if not py_files:
320
+ return {"tool": "ruff", "passed": True, "issues": []}
321
+ try:
322
+ result = subprocess.run(
323
+ [ruff_bin, "check", *py_files], capture_output=True, text=True, cwd=project_root, timeout=_timeout_lint(),
324
+ )
325
+ except subprocess.TimeoutExpired:
326
+ return {"tool": "ruff", "passed": True, "issues": [], "skipped": "timed out"}
327
+ except Exception as e:
328
+ # Previously uncaught -- a ruff crash/misconfig took down the whole
329
+ # gate-evaluation thread instead of degrading to a skip, worse than
330
+ # the silent-skip failure mode this is meant to guard against.
331
+ return {"tool": "ruff", "passed": True, "issues": [], "skipped": str(e)}
332
+ issues = [l for l in result.stdout.splitlines() if l.strip()]
333
+ return {"tool": "ruff", "passed": result.returncode == 0, "issues": issues}
334
+
335
+
336
+ def _run_eslint(project_root: Path, changed_files: list[str]) -> dict | None:
337
+ eslint_bin = project_root / "node_modules" / ".bin" / "eslint"
338
+ if not eslint_bin.exists():
339
+ return None
340
+ js_files = [f for f in changed_files if f.endswith((".js", ".jsx", ".ts", ".tsx"))]
341
+ if not js_files:
342
+ return {"tool": "eslint", "passed": True, "issues": []}
343
+ try:
344
+ result = subprocess.run(
345
+ [str(eslint_bin), *js_files], capture_output=True, text=True, cwd=project_root, timeout=_timeout_lint(),
346
+ )
347
+ except subprocess.TimeoutExpired:
348
+ return {"tool": "eslint", "passed": True, "issues": [], "skipped": "timed out"}
349
+ except Exception as e:
350
+ return {"tool": "eslint", "passed": True, "issues": [], "skipped": str(e)}
351
+ issues = [l for l in result.stdout.splitlines() if l.strip()]
352
+ return {"tool": "eslint", "passed": result.returncode == 0, "issues": issues}
353
+
354
+
355
+ def run_lint(project_root: Path, changed_files: list[str]) -> dict:
356
+ """Scoped to changed files only — fast, PR-diff-style check."""
357
+ for runner in (_run_ruff, _run_eslint):
358
+ out = runner(project_root, changed_files)
359
+ if out is not None:
360
+ return out
361
+ return {"tool": None, "passed": True, "issues": []}
362
+
363
+
364
+ def _run_python_coverage(project_root: Path) -> dict | None:
365
+ coverage_bin = project_tool(project_root, "coverage")
366
+ pytest_bin = project_tool(project_root, "pytest")
367
+ if not coverage_bin or not pytest_bin:
368
+ return None
369
+ try:
370
+ subprocess.run(
371
+ [coverage_bin, "run", "-m", "pytest", "-q"],
372
+ capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
373
+ )
374
+ result = subprocess.run(
375
+ [coverage_bin, "report", "--format=total"],
376
+ capture_output=True, text=True, cwd=project_root, timeout=30,
377
+ )
378
+ except subprocess.TimeoutExpired:
379
+ return {"tool": "coverage", "percent": None}
380
+ try:
381
+ return {"tool": "coverage", "percent": float(result.stdout.strip())}
382
+ except ValueError:
383
+ return {"tool": "coverage", "percent": None}
384
+
385
+
386
+ def _run_npm_coverage(project_root: Path) -> dict | None:
387
+ pkg = project_root / "package.json"
388
+ if not pkg.exists() or not shutil.which("npm"):
389
+ return None
390
+ try:
391
+ data = json.loads(pkg.read_text())
392
+ except Exception:
393
+ return None
394
+ if "coverage" not in data.get("scripts", {}):
395
+ return None
396
+ try:
397
+ result = subprocess.run(
398
+ ["npm", "run", "coverage", "--silent"],
399
+ capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
400
+ )
401
+ except subprocess.TimeoutExpired:
402
+ return {"tool": "npm coverage", "percent": None}
403
+ # istanbul/nyc/jest all print a summary row starting with "All files"
404
+ m = re.search(r"All files\s*\|\s*([\d.]+)", result.stdout)
405
+ return {"tool": "npm coverage", "percent": float(m.group(1)) if m else None}
406
+
407
+
408
+ def run_coverage(project_root: Path) -> dict:
409
+ """Best-effort test coverage %. Returns {"tool": None, "percent": None} if
410
+ no coverage tool is detected — never blocks, this is a tracked metric only."""
411
+ for runner in (_run_python_coverage, _run_npm_coverage):
412
+ out = runner(project_root)
413
+ if out is not None:
414
+ return out
415
+ return {"tool": None, "percent": None}
416
+
417
+
418
+ def run_sast(project_root: Path, changed_files: list[str]) -> dict:
419
+ """SAST + secret-scan via semgrep, if installed. Scoped to changed files."""
420
+ semgrep_bin = project_tool(project_root, "semgrep")
421
+ if not semgrep_bin or not changed_files:
422
+ return {"tool": None, "passed": True, "findings": []}
423
+ try:
424
+ result = subprocess.run(
425
+ [semgrep_bin, "--config=auto", "--quiet", "--error", *changed_files],
426
+ capture_output=True, text=True, cwd=project_root, timeout=_timeout_sast(),
427
+ )
428
+ except subprocess.TimeoutExpired:
429
+ return {"tool": "semgrep", "passed": True, "findings": [], "skipped": "timed out"}
430
+ except Exception as e:
431
+ return {"tool": "semgrep", "passed": True, "findings": [], "skipped": str(e)}
432
+ findings = [l for l in result.stdout.splitlines() if l.strip()]
433
+ if result.returncode != 0 and not findings:
434
+ # semgrep's --error flag exits non-zero for EITHER real findings OR a
435
+ # tool-level failure (network fetch for --config=auto, a scan error)
436
+ # -- indistinguishable by exit code alone. Real 2026-07-21 incident:
437
+ # this surfaced as "SAST found issues" with an empty evidence file,
438
+ # since nothing landed on stdout to write. Skip (don't block) rather
439
+ # than block on a phantom finding -- same posture as the timeout/
440
+ # exception branches above.
441
+ return {
442
+ "tool": "semgrep", "passed": True, "findings": [],
443
+ "skipped": (result.stderr or "semgrep exited non-zero with no findings on stdout")[-500:],
444
+ }
445
+ return {"tool": "semgrep", "passed": result.returncode == 0, "findings": findings}
pcp/run_log.py ADDED
@@ -0,0 +1,225 @@
1
+ """run_log.py — pre/post audit bracket around any development or test run.
2
+
3
+ Answers "where is the bluffing happening" by recording a PRE entry before a
4
+ run starts and a POST entry after it ends, linked by run_id, hash-chained
5
+ (same mechanism as telemetry.jsonl/decision_log.jsonl). The anomaly_flags on
6
+ a POST entry are computed deterministically from git state and the PRE
7
+ entry's own data -- never an LLM judgment about itself. A run claiming
8
+ "success" with no new commit, no test evidence, and only LLM-judged checks
9
+ having run is exactly the pattern this exists to make visible instead of
10
+ indistinguishable from real work in the same ledger.
11
+
12
+ Two ways an entry gets written:
13
+ 1. `pcp run-log start`/`end` -- a human or an interactive Claude Code session
14
+ brackets its own manual/direct-edit work. Token/cost fields here are
15
+ self-reported (self_reported_usage=True) -- there is no independent
16
+ source for an interactive session's own usage, and this module does not
17
+ pretend otherwise.
18
+ 2. build.py's `_build_one_criterion` calls start_run()/end_run() directly,
19
+ wired from the real `claude -p --output-format json` envelope
20
+ (self_reported_usage=False) -- the same real numbers token_ledger.yaml
21
+ already gets, just also bracketed here with git-state proof.
22
+ """
23
+
24
+ import hashlib
25
+ import json
26
+ import subprocess
27
+ import uuid
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ from pcp.evidence_chain import chain_entry
32
+ from pcp.objective_conflicts import objective_hash
33
+
34
+ _TS_FMT = "%Y-%m-%dT%H:%M:%SZ"
35
+
36
+ _DETERMINISTIC_CHECKS = {
37
+ "tests", "lint", "sast", "l1", "scope", "design_consistency",
38
+ "bvb_justification", "customization", "lazy_marker", "a11y",
39
+ }
40
+ _LLM_JUDGED_CHECKS = {"arch", "gate", "design_justification", "visual_quality"}
41
+
42
+
43
+ def _now() -> str:
44
+ return datetime.now(timezone.utc).strftime(_TS_FMT)
45
+
46
+
47
+ def _git_head(project_root: Path) -> str | None:
48
+ result = subprocess.run(
49
+ ["git", "rev-parse", "HEAD"], cwd=project_root, capture_output=True, text=True,
50
+ )
51
+ return result.stdout.strip() if result.returncode == 0 else None
52
+
53
+
54
+ def _pushed_to_remote(project_root: Path, sha: str | None) -> bool:
55
+ if not sha:
56
+ return False
57
+ result = subprocess.run(
58
+ ["git", "branch", "-r", "--contains", sha], cwd=project_root, capture_output=True, text=True,
59
+ )
60
+ return result.returncode == 0 and bool(result.stdout.strip())
61
+
62
+
63
+ def _ledger_path(pcp_dir: Path) -> Path:
64
+ return Path(pcp_dir) / "run_ledger.jsonl"
65
+
66
+
67
+ def _load(path: Path) -> list[dict]:
68
+ if not path.exists():
69
+ return []
70
+ records = []
71
+ for line in path.read_text().splitlines():
72
+ line = line.strip()
73
+ if not line:
74
+ continue
75
+ try:
76
+ records.append(json.loads(line))
77
+ except json.JSONDecodeError:
78
+ continue
79
+ return records
80
+
81
+
82
+ def _append(pcp_dir: Path, fields: dict) -> dict:
83
+ path = _ledger_path(pcp_dir)
84
+ records = _load(path)
85
+ prev_hash = records[-1].get("entry_hash") if records else None
86
+ entry = chain_entry(prev_hash, fields)
87
+ with open(path, "a") as f:
88
+ f.write(json.dumps(entry) + "\n")
89
+ return entry
90
+
91
+
92
+ def start_run(
93
+ pcp_dir: Path, *, module: str, feature: str, run_type: str, actor: str,
94
+ business_objective: str | None = None, model: str | None = None,
95
+ criterion_id: str | None = None, wave_number: int | None = None,
96
+ logic_tier: str | None = None, build_vs_buy: str | None = None,
97
+ internal_deps_declared: list[str] | None = None, internal_deps_real: list[str] | None = None,
98
+ ) -> str:
99
+ """PRE record. Returns run_id -- pass it to end_run() to close the bracket.
100
+
101
+ criterion_id/wave_number/logic_tier/build_vs_buy/internal_deps_declared
102
+ (2026-07-31): the "build capsule" fields -- BRD/module/submodule/wave/
103
+ scope/deps/completed/tested requested as a time-series record of what
104
+ got built. This ledger already had module/feature/tests/commit/cost/
105
+ objective_hash; these fill in the remaining self-declared fields at the
106
+ point they're known (kickoff/pm time), not recomputed later.
107
+ internal_deps_declared is exactly that -- DECLARED, not verified against
108
+ real coupling (see validate_strategy.py's hidden_coupling for the
109
+ actual-usage signal; the declared graph is known to drift from it)."""
110
+ project_root = Path(pcp_dir).parent
111
+ run_id = str(uuid.uuid4())
112
+ obj_text = business_objective
113
+ if obj_text is None:
114
+ obj_path = Path(pcp_dir) / "objective.md"
115
+ obj_text = obj_path.read_text()[:300] if obj_path.exists() else ""
116
+ _append(pcp_dir, {
117
+ "run_id": run_id, "phase": "pre", "timestamp": _now(),
118
+ "business_objective": obj_text, "objective_hash": objective_hash(Path(pcp_dir)),
119
+ "module": module, "feature": feature, "run_type": run_type, "actor": actor,
120
+ "start_time": _now(), "model": model, "pre_commit_sha": _git_head(project_root),
121
+ "criterion_id": criterion_id, "wave_number": wave_number,
122
+ "logic_tier": logic_tier, "build_vs_buy": build_vs_buy,
123
+ "internal_deps_declared": internal_deps_declared or [],
124
+ # internal_deps_real: actual git co-change coupling (coupling.py's
125
+ # compute_change_coupling, cached via validate-strategy's last run in
126
+ # hidden_coupling.json) -- distinct from internal_deps_declared,
127
+ # which is self-reported and known to drift from what's real in this
128
+ # codebase (see the standards_interop finding: 69% of blocking test
129
+ # failures in one dogfood project came from modules with ZERO
130
+ # declared dependency on it). Empty if validate-strategy has never
131
+ # run, or this module has no confirmed hidden coupling -- absence
132
+ # here means "not yet measured or none found", not "verified zero".
133
+ "internal_deps_real": internal_deps_real or [],
134
+ })
135
+ return run_id
136
+
137
+
138
+ def end_run(
139
+ pcp_dir: Path, run_id: str, *, result: str,
140
+ model: str | None = None, token_input: int = 0, token_output: int = 0,
141
+ token_cache_read: int = 0, cost_usd: float | None = None,
142
+ tests_ran: bool | None = None, tests_passed: bool | None = None,
143
+ real_gates_passed: list[str] | None = None, llm_judged_gates_passed: list[str] | None = None,
144
+ note: str | None = None, self_reported_usage: bool = False,
145
+ external_deps: list[str] | None = None,
146
+ ) -> dict:
147
+ """POST record, same run_id. anomaly_flags are computed here,
148
+ deterministically, from git state + the PRE record -- never from an LLM
149
+ asked to grade the run it just did."""
150
+ project_root = Path(pcp_dir).parent
151
+ records = _load(_ledger_path(pcp_dir))
152
+ pre = next(
153
+ (r for r in reversed(records) if r.get("run_id") == run_id and r.get("phase") == "pre"), None,
154
+ )
155
+
156
+ post_commit_sha = _git_head(project_root)
157
+ pre_commit_sha = pre.get("pre_commit_sha") if pre else None
158
+ committed = bool(post_commit_sha and post_commit_sha != pre_commit_sha)
159
+ pushed = _pushed_to_remote(project_root, post_commit_sha) if committed else False
160
+ real_gates_passed = real_gates_passed or []
161
+ llm_judged_gates_passed = llm_judged_gates_passed or []
162
+
163
+ proof = {
164
+ "tests_ran": tests_ran, "tests_passed": tests_passed,
165
+ "real_gates_passed": real_gates_passed, "llm_judged_gates_passed": llm_judged_gates_passed,
166
+ "committed": committed, "pushed": pushed, "post_commit_sha": post_commit_sha,
167
+ }
168
+
169
+ anomalies = []
170
+ if not committed:
171
+ anomalies.append("no_commit: claimed run produced no new commit")
172
+ if not real_gates_passed and llm_judged_gates_passed:
173
+ anomalies.append("all_self_judged: only LLM-judged checks ran, zero deterministic verification")
174
+ if tests_ran is False or tests_ran is None:
175
+ anomalies.append("no_test_evidence: no test-suite result recorded for this run")
176
+ if pre is not None and objective_hash(Path(pcp_dir)) != pre.get("objective_hash"):
177
+ anomalies.append("objective_drifted: objective.md changed mid-run — result may target a stale spec")
178
+ if result == "success" and not committed and not (tests_ran and tests_passed):
179
+ anomalies.append("unverified_success: claimed success with no commit AND no passing test evidence")
180
+
181
+ fields = {
182
+ "run_id": run_id, "phase": "post", "timestamp": _now(), "end_time": _now(),
183
+ "duration_sec": None, "model": model,
184
+ "token_input": token_input, "token_output": token_output, "token_cache_read": token_cache_read,
185
+ "cost_usd": cost_usd, "self_reported_usage": self_reported_usage,
186
+ "result": result, "proof_of_delivery": proof, "note": note, "anomaly_flags": anomalies,
187
+ "external_deps": external_deps or [],
188
+ }
189
+ if pre is not None:
190
+ try:
191
+ start_dt = datetime.strptime(pre["start_time"], _TS_FMT).replace(tzinfo=timezone.utc)
192
+ end_dt = datetime.strptime(fields["end_time"], _TS_FMT).replace(tzinfo=timezone.utc)
193
+ fields["duration_sec"] = (end_dt - start_dt).total_seconds()
194
+ except (KeyError, ValueError):
195
+ pass
196
+
197
+ return _append(pcp_dir, fields)
198
+
199
+
200
+ def load(pcp_dir: Path) -> list[dict]:
201
+ return _load(_ledger_path(pcp_dir))
202
+
203
+
204
+ def pair_runs(records: list[dict]) -> list[dict]:
205
+ """One row per completed run (pre+post merged), newest last. A pre with
206
+ no matching post yet (run still open, or the process died mid-run)
207
+ is surfaced separately by callers that want it -- never silently
208
+ dropped here."""
209
+ pres = {r["run_id"]: r for r in records if r.get("phase") == "pre" and r.get("run_id")}
210
+ pairs = []
211
+ for r in records:
212
+ if r.get("phase") != "post":
213
+ continue
214
+ pre = pres.get(r.get("run_id"), {})
215
+ pairs.append({**pre, **r})
216
+ return pairs
217
+
218
+
219
+ def open_runs(records: list[dict]) -> list[dict]:
220
+ """PRE records with no matching POST -- a run that started and never
221
+ closed (crash, forgotten `end`, killed process). Not an error by
222
+ itself, but worth surfacing: an open run is one more way real work can
223
+ go unaccounted."""
224
+ closed_ids = {r["run_id"] for r in records if r.get("phase") == "post" and r.get("run_id")}
225
+ return [r for r in records if r.get("phase") == "pre" and r.get("run_id") not in closed_ids]
pcp/schema/__init__.py ADDED
File without changes