program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""pcp validate-strategy — Pass 2 pioneer claim.
|
|
2
|
+
|
|
3
|
+
Checks whether module specs collectively cover the program objective.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import yaml
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich import print as rprint
|
|
15
|
+
|
|
16
|
+
from pcp.pcp_dir import find_pcp_dir, get_modules_dir, get_objective, get_decomposition, NoPCPDir
|
|
17
|
+
from pcp.schema.validator import validate_file, load_yaml
|
|
18
|
+
from pcp.llm import client as llm
|
|
19
|
+
from pcp import coupling as coupling_lib
|
|
20
|
+
from pcp import assertions as assertions_lib
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
# Coupling (coupling_score, coupling_violations) used to be asked of the LLM
|
|
25
|
+
# here — but it's graph math (circular deps, dependency counts, god modules),
|
|
26
|
+
# fully deterministic from each module's declared 'dependencies' field. See
|
|
27
|
+
# coupling.py. The LLM now only handles what's genuinely semantic: does the
|
|
28
|
+
# set of module specs, taken together, actually cover the stated objective.
|
|
29
|
+
SYSTEM_PROMPT = """\
|
|
30
|
+
You are a program-context auditor. Your job is to check whether a set of \
|
|
31
|
+
module specifications collectively and fully cover a stated program objective.
|
|
32
|
+
|
|
33
|
+
CRITICAL DISTINCTION — modules vs external systems:
|
|
34
|
+
The objective may name external systems, testbed targets, third-party services, \
|
|
35
|
+
or example integrations. These are NOT missing modules. They are systems \
|
|
36
|
+
OUTSIDE this codebase that will use or integrate with it. Examples: \
|
|
37
|
+
"test against StripeAPI", "integrate with AlphaForge", "connect to Slack" — \
|
|
38
|
+
these name external targets, not internal modules to build. Only report a \
|
|
39
|
+
missing_module when a CAPABILITY owned by this system has no module that covers it. \
|
|
40
|
+
Never suggest a missing module for an external system, third-party service, \
|
|
41
|
+
or named integration target.
|
|
42
|
+
|
|
43
|
+
You must output ONLY valid JSON — no prose, no markdown, no code fences.
|
|
44
|
+
|
|
45
|
+
Output schema:
|
|
46
|
+
{
|
|
47
|
+
"coverage_gaps": [
|
|
48
|
+
{"area": "string", "quote": "string from objective"}
|
|
49
|
+
],
|
|
50
|
+
"contradictions": [
|
|
51
|
+
{"module": "string", "conflict": "string", "objective_quote": "string"}
|
|
52
|
+
],
|
|
53
|
+
"overlaps": [
|
|
54
|
+
{"modules": ["string"], "area": "string"}
|
|
55
|
+
],
|
|
56
|
+
"missing_modules": [
|
|
57
|
+
{"name": "string", "reason": "string", "is_internal_capability": true}
|
|
58
|
+
],
|
|
59
|
+
"coverage_score": 0.0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
coverage_score: 0.0 (nothing covered) to 1.0 (fully covered). Be precise.
|
|
63
|
+
|
|
64
|
+
missing_modules: only include capabilities this system must own but has no module for. \
|
|
65
|
+
Set is_internal_capability: true only when certain. When in doubt, omit the entry.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_user_prompt(objective: str, decomposition: str | None, modules: dict[str, dict]) -> str:
|
|
70
|
+
parts = [f"## Program Objective\n\n{objective}\n"]
|
|
71
|
+
|
|
72
|
+
if decomposition:
|
|
73
|
+
parts.append(f"## Decomposition Rationale\n\n{decomposition}\n")
|
|
74
|
+
|
|
75
|
+
parts.append("## Module Specifications\n")
|
|
76
|
+
for name, spec in modules.items():
|
|
77
|
+
parts.append(f"### {name}\n```yaml\n{yaml.dump(spec, default_flow_style=False)}```\n")
|
|
78
|
+
|
|
79
|
+
return "\n".join(parts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_modules(modules_dir: Path) -> dict[str, dict]:
|
|
83
|
+
modules = {}
|
|
84
|
+
if not modules_dir.exists():
|
|
85
|
+
return modules
|
|
86
|
+
for spec_path in sorted(modules_dir.glob("*/spec.yaml")):
|
|
87
|
+
module_name = spec_path.parent.name
|
|
88
|
+
errors = validate_file(spec_path, "module_spec")
|
|
89
|
+
if errors:
|
|
90
|
+
console.print(f"[yellow]⚠ {spec_path.relative_to(modules_dir.parent.parent)}: schema errors[/yellow]")
|
|
91
|
+
for e in errors:
|
|
92
|
+
console.print(f" {e}")
|
|
93
|
+
spec = load_yaml(spec_path)
|
|
94
|
+
if spec.get("deprecated"):
|
|
95
|
+
console.print(f"[dim]skipping deprecated module: {module_name}[/dim]")
|
|
96
|
+
continue
|
|
97
|
+
modules[module_name] = spec
|
|
98
|
+
return modules
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _add_deterministic_coverage(result: dict, objective: str, modules: dict[str, dict]) -> dict:
|
|
102
|
+
"""Overrides the LLM-judged coverage_score/coverage_gaps with a
|
|
103
|
+
deterministic keyword-overlap score when objective.md has a numbered
|
|
104
|
+
assertion list to score against — same Goodhart-mitigation move
|
|
105
|
+
coupling.py already made for coupling_score (see assertions.py's own
|
|
106
|
+
docstring for the full reasoning). Falls through to the LLM's own
|
|
107
|
+
coverage judgment unchanged when objective.md has no numbered list (an
|
|
108
|
+
old-format file) — never hard-breaks backward compatibility. The LLM's
|
|
109
|
+
own coverage_score is kept (not discarded) under llm_coverage_score so
|
|
110
|
+
the two can be compared, even when the deterministic one wins."""
|
|
111
|
+
assertions = assertions_lib.parse_assertions(objective)
|
|
112
|
+
if not assertions:
|
|
113
|
+
result["scoring_method"] = "llm"
|
|
114
|
+
return result
|
|
115
|
+
det = assertions_lib.compute_coverage(assertions, modules)
|
|
116
|
+
result["llm_coverage_score"] = result.get("coverage_score")
|
|
117
|
+
result["coverage_score"] = det["coverage_score"]
|
|
118
|
+
result["coverage_gaps"] = det["coverage_gaps"]
|
|
119
|
+
result["assertions_total"] = det["assertions_total"]
|
|
120
|
+
result["assertions_covered"] = det["assertions_covered"]
|
|
121
|
+
result["assertion_coverage_map"] = det["assertion_coverage_map"]
|
|
122
|
+
result["scoring_method"] = "deterministic"
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _add_coupling(
|
|
127
|
+
result: dict, modules: dict[str, dict], project_root: Path | None = None, pcp_dir: Path | None = None,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Merge deterministic coupling analysis into the LLM's coverage-only result."""
|
|
130
|
+
graph = coupling_lib.build_dependency_graph(modules)
|
|
131
|
+
result.update(coupling_lib.compute_coupling(graph))
|
|
132
|
+
result["communities"] = coupling_lib.compute_communities(graph)
|
|
133
|
+
# Second, independent signal (CodeScene pattern): git-history co-change
|
|
134
|
+
# coupling the static import graph can't see. Advisory only — surfaced,
|
|
135
|
+
# never scored.
|
|
136
|
+
if project_root is not None:
|
|
137
|
+
try:
|
|
138
|
+
result["hidden_coupling"] = coupling_lib.compute_change_coupling(project_root, modules)
|
|
139
|
+
except Exception:
|
|
140
|
+
result["hidden_coupling"] = []
|
|
141
|
+
# Persisted (2026-07-31) so build.py's run_ledger capsule can attach
|
|
142
|
+
# the REAL coupling signal to internal_deps without re-running this
|
|
143
|
+
# git-log scan per criterion -- a cheap cached read instead of
|
|
144
|
+
# recomputing an expensive project-wide subprocess inside the build
|
|
145
|
+
# hot loop. Simple last-run cache, not an audit ledger: overwritten
|
|
146
|
+
# every validate-strategy run, no hash chain.
|
|
147
|
+
if pcp_dir is not None:
|
|
148
|
+
try:
|
|
149
|
+
(pcp_dir / "hidden_coupling.json").write_text(json.dumps(result["hidden_coupling"], indent=2))
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _add_tier_distribution(pcp_dir: Path, result: dict) -> dict:
|
|
156
|
+
"""Logic-tier distribution policy (2026-07-18). A project can pass every
|
|
157
|
+
other gate while 80% of its criteria quietly declare rung 6 — 'all LLM
|
|
158
|
+
all the time' is a legitimate strategy only if chosen deliberately.
|
|
159
|
+
Deterministic count over acceptance.yaml files; bands human-editable via
|
|
160
|
+
.pcp/policies/tier_distribution.rego (same pattern as coupling_threshold),
|
|
161
|
+
hardcoded fallback: rung-6 share green <=0.35, yellow <=0.6, else red.
|
|
162
|
+
ADVISORY — surfaced, never blocks; predictability budget is a per-project
|
|
163
|
+
judgment call, the policy file is where a team encodes theirs."""
|
|
164
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
165
|
+
dist: dict[int, int] = {}
|
|
166
|
+
total = 0
|
|
167
|
+
for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
168
|
+
for c in (load_yaml(acc_path) or {}).get("criteria", []):
|
|
169
|
+
tier = c.get("logic_tier")
|
|
170
|
+
if isinstance(tier, int):
|
|
171
|
+
dist[tier] = dist.get(tier, 0) + 1
|
|
172
|
+
total += 1
|
|
173
|
+
if not total:
|
|
174
|
+
return result
|
|
175
|
+
rung6_share = dist.get(6, 0) / total
|
|
176
|
+
from pcp import policy
|
|
177
|
+
decision = policy.evaluate(pcp_dir, "data.pcp.tier_distribution.color", {"rung6_share": rung6_share})
|
|
178
|
+
if decision.get("available") and not decision.get("undefined") and decision.get("value"):
|
|
179
|
+
color = decision["value"]
|
|
180
|
+
else:
|
|
181
|
+
color = "green" if rung6_share <= 0.35 else "yellow" if rung6_share <= 0.6 else "red"
|
|
182
|
+
result["tier_distribution"] = {str(k): v for k, v in sorted(dist.items())}
|
|
183
|
+
result["rung6_share"] = round(rung6_share, 2)
|
|
184
|
+
result["tier_distribution_color"] = color
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _add_coverage_audit(pcp_dir: Path, result: dict, objective: str, modules: dict[str, dict]) -> dict:
|
|
189
|
+
"""Goodhart mitigation on the LLM-judged coverage_score (see coverage_audit.py):
|
|
190
|
+
never corrects the score, only surfaces internal-inconsistency and drift
|
|
191
|
+
findings so a high score can't quietly substitute for a real gap-free check."""
|
|
192
|
+
from pcp import coverage_audit
|
|
193
|
+
findings = coverage_audit.record(
|
|
194
|
+
pcp_dir, result.get("coverage_score", 0.0), result.get("coverage_gaps", []), objective, modules,
|
|
195
|
+
)
|
|
196
|
+
result["coverage_audit_findings"] = findings
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coupling_color(pcp_dir: Path, coupling_score: float) -> str:
|
|
201
|
+
"""Prefer the human-editable Rego policy (.pcp/policies/coupling_threshold.rego)
|
|
202
|
+
over the hardcoded bands below -- falls back to the hardcoded bands if opa
|
|
203
|
+
isn't installed or no policy is scaffolded, so this never hard-depends on OPA."""
|
|
204
|
+
from pcp import policy
|
|
205
|
+
decision = policy.evaluate(pcp_dir, "data.pcp.coupling.coupling_color", {"coupling_score": coupling_score})
|
|
206
|
+
if decision.get("available") and not decision.get("undefined") and decision.get("value"):
|
|
207
|
+
return decision["value"]
|
|
208
|
+
return "green" if coupling_score >= 0.8 else "yellow" if coupling_score >= 0.6 else "red"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _render_results(pcp_dir: Path, result: dict, output_json: bool) -> int:
|
|
212
|
+
if output_json:
|
|
213
|
+
click.echo(json.dumps(result, indent=2))
|
|
214
|
+
gaps = result.get("coverage_gaps", [])
|
|
215
|
+
return 1 if gaps else 0
|
|
216
|
+
|
|
217
|
+
if result.get("tier_distribution") is not None:
|
|
218
|
+
color = result.get("tier_distribution_color", "green")
|
|
219
|
+
share = result.get("rung6_share", 0.0)
|
|
220
|
+
console.print(
|
|
221
|
+
f"[{color}]Logic-tier mix:[/{color}] {result['tier_distribution']} — "
|
|
222
|
+
f"rung-6 (LLM) share {share:.0%} "
|
|
223
|
+
+ ("" if color == "green" else "(advisory: high LLM share — deliberate choice, or tier laziness? "
|
|
224
|
+
"Bands editable in .pcp/policies/tier_distribution.rego)")
|
|
225
|
+
)
|
|
226
|
+
hidden = result.get("hidden_coupling") or []
|
|
227
|
+
for h in hidden:
|
|
228
|
+
console.print(
|
|
229
|
+
f"[yellow]Hidden coupling (advisory):[/yellow] {h['modules'][0]} ↔ {h['modules'][1]} "
|
|
230
|
+
f"co-changed {h['co_changes']}x (ratio {h['ratio']}) with no declared dependency"
|
|
231
|
+
)
|
|
232
|
+
score = result.get("coverage_score", 0.0)
|
|
233
|
+
coupling_score = result.get("coupling_score", 1.0)
|
|
234
|
+
gaps = result.get("coverage_gaps", [])
|
|
235
|
+
contradictions = result.get("contradictions", [])
|
|
236
|
+
overlaps = result.get("overlaps", [])
|
|
237
|
+
missing = result.get("missing_modules", [])
|
|
238
|
+
coupling_violations = result.get("coupling_violations", [])
|
|
239
|
+
|
|
240
|
+
score_color = "green" if score >= 0.8 else "yellow" if score >= 0.5 else "red"
|
|
241
|
+
coupling_color = _coupling_color(pcp_dir, coupling_score)
|
|
242
|
+
|
|
243
|
+
method = result.get("scoring_method", "llm")
|
|
244
|
+
method_label = "deterministic — keyword-overlap graph reachability" if method == "deterministic" else "LLM-judged"
|
|
245
|
+
console.print(f"\n[bold]Coverage score:[/bold] [{score_color}]{score:.0%}[/{score_color}] [dim]({method_label})[/dim]")
|
|
246
|
+
if method == "deterministic":
|
|
247
|
+
console.print(f"[dim] {result.get('assertions_covered', 0)}/{result.get('assertions_total', 0)} "
|
|
248
|
+
f"objective assertions covered — LLM's own judgment was {result.get('llm_coverage_score', 0):.0%}[/dim]")
|
|
249
|
+
for finding in result.get("coverage_audit_findings", []):
|
|
250
|
+
console.print(f" [yellow]⚠ {finding}[/yellow]")
|
|
251
|
+
console.print(f"[bold]Coupling score:[/bold] [{coupling_color}]{coupling_score:.0%}[/{coupling_color}] "
|
|
252
|
+
f"[dim](1.0 = fully decoupled, pivots are cheap)[/dim]\n")
|
|
253
|
+
|
|
254
|
+
if gaps:
|
|
255
|
+
console.print("[bold red]Coverage gaps[/bold red]")
|
|
256
|
+
for g in gaps:
|
|
257
|
+
console.print(f" ⚠ {g['area']}")
|
|
258
|
+
if g.get("quote"):
|
|
259
|
+
console.print(f" [dim]→ \"{g['quote']}\"[/dim]")
|
|
260
|
+
|
|
261
|
+
if contradictions:
|
|
262
|
+
console.print("\n[bold red]Contradictions[/bold red]")
|
|
263
|
+
for c in contradictions:
|
|
264
|
+
console.print(f" ✗ [cyan]{c['module']}[/cyan]: {c['conflict']}")
|
|
265
|
+
|
|
266
|
+
severe_coupling = [v for v in coupling_violations if v.get("type") in ("circular", "god_module", "shared_state")]
|
|
267
|
+
|
|
268
|
+
if coupling_violations:
|
|
269
|
+
# Only circular/god_module/shared_state actually fail this check (see
|
|
270
|
+
# has_failures below). Heading every list with "fix before build" told
|
|
271
|
+
# users that a plain direct dependency — the normal shape of any
|
|
272
|
+
# pipeline architecture — was a build blocker, and the clarifying
|
|
273
|
+
# "informational, not blocking" line below only printed when there
|
|
274
|
+
# were no coverage gaps, i.e. it was suppressed exactly when the user
|
|
275
|
+
# was most alarmed. Observed 2026-07-27: a clean 6-module linear
|
|
276
|
+
# pipeline reported six "Coupling violations (fix before build)".
|
|
277
|
+
if severe_coupling:
|
|
278
|
+
console.print("\n[bold red]Coupling violations[/bold red] [dim](fix before build — each makes pivoting expensive)[/dim]")
|
|
279
|
+
else:
|
|
280
|
+
console.print("\n[bold]Coupling notes[/bold] [dim](informational — direct dependencies do not block the build)[/dim]")
|
|
281
|
+
for v in coupling_violations:
|
|
282
|
+
mods = " ↔ ".join(v.get("modules", []))
|
|
283
|
+
vtype = v.get("type", "unknown")
|
|
284
|
+
desc = v.get("description", "")
|
|
285
|
+
fix = v.get("fix", "")
|
|
286
|
+
type_color = "red" if vtype in ("circular", "god_module") else "yellow"
|
|
287
|
+
console.print(f" [{type_color}]{vtype}[/{type_color}] {mods}: {desc}")
|
|
288
|
+
if fix:
|
|
289
|
+
console.print(f" [dim]→ fix: {fix}[/dim]")
|
|
290
|
+
|
|
291
|
+
if overlaps:
|
|
292
|
+
console.print("\n[bold yellow]Overlaps[/bold yellow]")
|
|
293
|
+
for o in overlaps:
|
|
294
|
+
mods = ", ".join(o["modules"])
|
|
295
|
+
console.print(f" ⚠ [{mods}]: {o['area']}")
|
|
296
|
+
|
|
297
|
+
if missing:
|
|
298
|
+
console.print("\n[bold yellow]Missing modules[/bold yellow]")
|
|
299
|
+
for m in missing:
|
|
300
|
+
console.print(f" ⚠ {m['name']}: {m['reason']}")
|
|
301
|
+
|
|
302
|
+
if not gaps and not contradictions and not missing and not coupling_violations:
|
|
303
|
+
console.print("[green]✓ All objective areas covered. No contradictions. No coupling violations.[/green]")
|
|
304
|
+
elif not gaps and not contradictions and not missing and coupling_violations:
|
|
305
|
+
if severe_coupling:
|
|
306
|
+
console.print("[yellow]Coverage complete — but coupling violations mean pivots will be expensive.[/yellow]")
|
|
307
|
+
else:
|
|
308
|
+
console.print("[dim]Coverage complete. Direct dependencies listed above are informational, not blocking.[/dim]")
|
|
309
|
+
|
|
310
|
+
communities = result.get("communities") or {}
|
|
311
|
+
if communities.get("available") and communities.get("communities"):
|
|
312
|
+
console.print("\n[bold]Module clusters (graphify community detection)[/bold] [dim]informal coupling, doesn't affect score[/dim]")
|
|
313
|
+
for cid, nodes in communities["communities"].items():
|
|
314
|
+
cohesion = communities.get("cohesion", {}).get(cid)
|
|
315
|
+
cohesion_str = f" (cohesion {cohesion:.2f})" if cohesion is not None else ""
|
|
316
|
+
console.print(f" cluster {cid}: {', '.join(nodes)}{cohesion_str}")
|
|
317
|
+
|
|
318
|
+
# Direct dependencies alone (no circularity, no god modules) are informational,
|
|
319
|
+
# not blocking — most real projects have some. Only severe coupling and
|
|
320
|
+
# coverage gaps fail the check.
|
|
321
|
+
#
|
|
322
|
+
# Scorer-consensus (2026-07-27): the same rule `pcp build`'s wave gate has
|
|
323
|
+
# applied since 2026-07-17, which standalone validate-strategy never got —
|
|
324
|
+
# so the two commands reached opposite verdicts on identical data, `build`
|
|
325
|
+
# calling it advisory while this exited 1. Keyword overlap has systematic
|
|
326
|
+
# false negatives when an objective's numbered list uses different
|
|
327
|
+
# vocabulary from module coverage text; measured across four real projects
|
|
328
|
+
# the deterministic score ranged 0%-100% on healthy decompositions. Two
|
|
329
|
+
# scorers disagreeing is uncertainty, not a verdict. Severe coupling still
|
|
330
|
+
# always fails: that is graph math, no second opinion needed.
|
|
331
|
+
coverage_advisory = bool(gaps) and assertions_lib.scorers_disagree(result) and not severe_coupling
|
|
332
|
+
if coverage_advisory:
|
|
333
|
+
console.print(
|
|
334
|
+
f"\n[yellow]Coverage gaps above are ADVISORY, not failures:[/yellow] the deterministic "
|
|
335
|
+
f"keyword scorer reads {result.get('coverage_score', 0):.0%} while the LLM's own judgment "
|
|
336
|
+
f"is {result.get('llm_coverage_score', 0):.0%}. Two scorers disagreeing is an uncertainty "
|
|
337
|
+
f"signal — review the gaps, but this is not a blocking result."
|
|
338
|
+
)
|
|
339
|
+
has_failures = bool((gaps and not coverage_advisory) or severe_coupling)
|
|
340
|
+
return 1 if has_failures else 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run_validate_strategy(pcp_dir: Path, command: str = "validate-strategy") -> dict | None:
|
|
344
|
+
"""Reusable core — same check the CLI command runs, returns the result dict
|
|
345
|
+
(or None if there's no objective/modules to check yet). Used by `pcp build`'s
|
|
346
|
+
wave-merge gate to re-check coverage/coupling after a wave completes."""
|
|
347
|
+
objective_path = get_objective(pcp_dir)
|
|
348
|
+
if not objective_path.exists():
|
|
349
|
+
return None
|
|
350
|
+
objective = objective_path.read_text()
|
|
351
|
+
decomp_path = get_decomposition(pcp_dir)
|
|
352
|
+
decomposition = decomp_path.read_text() if decomp_path.exists() else None
|
|
353
|
+
modules = _load_modules(get_modules_dir(pcp_dir))
|
|
354
|
+
if not modules:
|
|
355
|
+
return None
|
|
356
|
+
user_prompt = _build_user_prompt(objective, decomposition, modules)
|
|
357
|
+
result = llm.call_json(SYSTEM_PROMPT, user_prompt, model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command=command)
|
|
358
|
+
result = _add_deterministic_coverage(result, objective, modules)
|
|
359
|
+
result = _add_coupling(result, modules, project_root=pcp_dir.parent, pcp_dir=pcp_dir)
|
|
360
|
+
result = _add_tier_distribution(pcp_dir, result)
|
|
361
|
+
return _add_coverage_audit(pcp_dir, result, objective, modules)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@click.command()
|
|
365
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
366
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
367
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
368
|
+
def validate_strategy(output_json: bool, project_path: str | None):
|
|
369
|
+
"""Check whether module specs cover the program objective."""
|
|
370
|
+
try:
|
|
371
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
372
|
+
except NoPCPDir as e:
|
|
373
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
374
|
+
sys.exit(2)
|
|
375
|
+
|
|
376
|
+
objective_path = get_objective(pcp_dir)
|
|
377
|
+
if not objective_path.exists():
|
|
378
|
+
console.print("[red]Error:[/red] .pcp/objective.md not found.")
|
|
379
|
+
sys.exit(2)
|
|
380
|
+
|
|
381
|
+
objective = objective_path.read_text()
|
|
382
|
+
|
|
383
|
+
decomp_path = get_decomposition(pcp_dir)
|
|
384
|
+
decomposition = decomp_path.read_text() if decomp_path.exists() else None
|
|
385
|
+
|
|
386
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
387
|
+
modules = _load_modules(modules_dir)
|
|
388
|
+
|
|
389
|
+
if not modules:
|
|
390
|
+
console.print("[yellow]No module specs found in .pcp/strategy/modules/.[/yellow]")
|
|
391
|
+
console.print("Create at least one module: .pcp/strategy/modules/<name>/spec.yaml")
|
|
392
|
+
sys.exit(2)
|
|
393
|
+
|
|
394
|
+
if not output_json:
|
|
395
|
+
console.print(f"[dim]Checking {len(modules)} module(s) against objective...[/dim]")
|
|
396
|
+
|
|
397
|
+
user_prompt = _build_user_prompt(objective, decomposition, modules)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
result = llm.call_json(SYSTEM_PROMPT, user_prompt, model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="validate-strategy")
|
|
401
|
+
except RuntimeError as e:
|
|
402
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
403
|
+
sys.exit(2)
|
|
404
|
+
except ValueError as e:
|
|
405
|
+
console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
|
|
406
|
+
sys.exit(2)
|
|
407
|
+
|
|
408
|
+
result = _add_deterministic_coverage(result, objective, modules)
|
|
409
|
+
result = _add_coupling(result, modules, project_root=pcp_dir.parent, pcp_dir=pcp_dir)
|
|
410
|
+
result = _add_coverage_audit(pcp_dir, result, objective, modules)
|
|
411
|
+
|
|
412
|
+
exit_code = _render_results(pcp_dir, result, output_json)
|
|
413
|
+
sys.exit(exit_code)
|
pcp/commands/verify.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""pcp verify — the missing "this is genuinely done, record it" command.
|
|
2
|
+
|
|
3
|
+
Root-caused 2026-07-30: 12 criteria on Project O read `status: complete`
|
|
4
|
+
with `verified_by: None`. `verified_by` is stamped ONLY by `_mark_criterion_complete`
|
|
5
|
+
inside `pcp build`'s real gated loop, so its absence on a `complete` criterion means
|
|
6
|
+
the status was hand-edited into `acceptance.yaml` directly.
|
|
7
|
+
|
|
8
|
+
That happened because there was no other path. `pcp pm` regenerates criteria from
|
|
9
|
+
a natural-language intent -- it is a spec-authoring tool, not a "record this as
|
|
10
|
+
done" tool, and using it to flip one field is both the wrong instrument and not
|
|
11
|
+
what anyone actually reached for. People opened the YAML instead, which is exactly
|
|
12
|
+
the failure Hard Rule 2 exists to prevent, and it left the exact ambiguity
|
|
13
|
+
`verified_by` was invented to remove: PCP can no longer tell "passed every gate"
|
|
14
|
+
from "someone edited the file".
|
|
15
|
+
|
|
16
|
+
This command re-runs the criterion's own deterministic check
|
|
17
|
+
(`file_exists`/`ast_pattern`, via `scan.py`'s existing `_evaluate_criterion`) where
|
|
18
|
+
one exists, and requires an explicit `--reason` plus confirmation where it does not
|
|
19
|
+
(`manual`/`test_passes`/UAT checks) -- the same accountability posture as
|
|
20
|
+
`[pcp-bypass: reason]` and `pcp objective-conflicts --dismiss`, never a silent flip.
|
|
21
|
+
Every verification is logged to `decision_log.jsonl`, so "why does PCP believe this
|
|
22
|
+
is done" has an answer PCP itself wrote down, and it is human-approved: it only
|
|
23
|
+
writes `acceptance.yaml` after producing a real diff and getting confirmation, the
|
|
24
|
+
same propose->diff->approve->write shape as `pcp amend`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import click
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
34
|
+
from pcp.schema.validator import load_yaml
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_criterion(pcp_dir: Path, module: str, criterion_id: str) -> tuple[Path, dict, dict] | None:
|
|
40
|
+
"""(acc_path, acc_data, criterion) or None if not found."""
|
|
41
|
+
acc_path = pcp_dir / "strategy" / "modules" / module / "acceptance.yaml"
|
|
42
|
+
if not acc_path.exists():
|
|
43
|
+
return None
|
|
44
|
+
acc_data = load_yaml(acc_path) or {}
|
|
45
|
+
for c in acc_data.get("criteria", []) or []:
|
|
46
|
+
if c.get("id") == criterion_id:
|
|
47
|
+
return acc_path, acc_data, c
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _evaluate(pcp_dir: Path, project_root: Path, module: str, criterion: dict) -> tuple[str, str]:
|
|
52
|
+
"""Reuses scan.py's own evaluator so 'verify' and 'scan' never disagree
|
|
53
|
+
about what a check means -- two independent judges of the same fact is
|
|
54
|
+
how the pending/complete mismatch happened in the first place."""
|
|
55
|
+
from pcp.commands.scan import _evaluate_criterion
|
|
56
|
+
spec_path = pcp_dir / "strategy" / "modules" / module / "spec.yaml"
|
|
57
|
+
spec = load_yaml(spec_path) if spec_path.exists() else {}
|
|
58
|
+
return _evaluate_criterion(criterion, module, project_root, {}, spec, pcp_dir)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@click.command()
|
|
62
|
+
@click.argument("module")
|
|
63
|
+
@click.argument("criterion_id")
|
|
64
|
+
@click.option("--reason", default=None,
|
|
65
|
+
help="Required for manual/test_passes/UAT checks -- why this is genuinely done.")
|
|
66
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
67
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
68
|
+
help="Project root override.")
|
|
69
|
+
def verify(module: str, criterion_id: str, reason: str | None, yes: bool, project_path: str | None):
|
|
70
|
+
"""Record that MODULE's CRITERION_ID is genuinely built and complete.
|
|
71
|
+
|
|
72
|
+
The gated write path for a criterion's status -- never hand-edit
|
|
73
|
+
acceptance.yaml. Deterministic checks (file_exists, ast_pattern) are
|
|
74
|
+
re-verified automatically; manual/test_passes/UAT checks require --reason.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
78
|
+
except NoPCPDir as e:
|
|
79
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
80
|
+
sys.exit(2)
|
|
81
|
+
project_root = pcp_dir.parent
|
|
82
|
+
|
|
83
|
+
found = _find_criterion(pcp_dir, module, criterion_id)
|
|
84
|
+
if not found:
|
|
85
|
+
console.print(f"[red]Error:[/red] no criterion '{criterion_id}' found in module '{module}'.")
|
|
86
|
+
sys.exit(2)
|
|
87
|
+
acc_path, acc_data, criterion = found
|
|
88
|
+
|
|
89
|
+
if criterion.get("status") == "complete" and criterion.get("verified_by"):
|
|
90
|
+
console.print(
|
|
91
|
+
f"[green]{module}/{criterion_id}[/green] is already complete, "
|
|
92
|
+
f"verified by [dim]{criterion['verified_by']}[/dim]. Nothing to do."
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
check = criterion.get("check", "manual")
|
|
97
|
+
deterministic = check in ("file_exists", "ast_pattern", "url_responds", "dom_contains", "visual")
|
|
98
|
+
|
|
99
|
+
if deterministic:
|
|
100
|
+
status, detail = _evaluate(pcp_dir, project_root, module, criterion)
|
|
101
|
+
if status != "complete":
|
|
102
|
+
console.print(
|
|
103
|
+
f"[red]Refused:[/red] {module}/{criterion_id} declares check: {check}, "
|
|
104
|
+
f"and re-running it says NOT complete:"
|
|
105
|
+
)
|
|
106
|
+
console.print(f" [dim]{detail}[/dim]")
|
|
107
|
+
console.print(
|
|
108
|
+
"\n[dim]This is what `pcp verify` is for -- a deterministic check disagreeing "
|
|
109
|
+
"with a hand-set 'complete' status is exactly the ambiguity it exists to catch.[/dim]"
|
|
110
|
+
)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
console.print(f"[green]Deterministic check ({check}) confirms it:[/green] {detail}")
|
|
113
|
+
source = f"pcp_verify:{check}"
|
|
114
|
+
else:
|
|
115
|
+
if not reason or not reason.strip():
|
|
116
|
+
console.print(
|
|
117
|
+
f"[red]Error:[/red] {module}/{criterion_id} declares check: {check}, which has no "
|
|
118
|
+
"deterministic re-check. --reason is required -- state the concrete evidence "
|
|
119
|
+
"(which tests pass, which commit, what you observed), the same accountability "
|
|
120
|
+
"[pcp-bypass: reason] requires for a bypass."
|
|
121
|
+
)
|
|
122
|
+
sys.exit(2)
|
|
123
|
+
source = "pcp_verify:manual"
|
|
124
|
+
|
|
125
|
+
console.print(f"\n[bold]{module}/{criterion_id}[/bold]: {criterion.get('description', '')}")
|
|
126
|
+
console.print(f" check: {check} current status: {criterion.get('status')}")
|
|
127
|
+
if reason:
|
|
128
|
+
console.print(f" reason: {reason}")
|
|
129
|
+
|
|
130
|
+
if not yes and not click.confirm("\nMark this criterion complete?", default=False):
|
|
131
|
+
console.print("[yellow]Not marked. No changes written.[/yellow]")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
for c in acc_data.get("criteria", []):
|
|
135
|
+
if c.get("id") == criterion_id:
|
|
136
|
+
c["status"] = "complete"
|
|
137
|
+
c["verified_by"] = source
|
|
138
|
+
acc_path.write_text(__import__("yaml").dump(acc_data, default_flow_style=False))
|
|
139
|
+
|
|
140
|
+
from pcp import decision_log
|
|
141
|
+
decision_log.record(
|
|
142
|
+
pcp_dir, source="pcp_verify", category="manual-verification",
|
|
143
|
+
module=module, criterion_id=criterion_id,
|
|
144
|
+
summary=f"{module}/{criterion_id} marked complete via pcp verify ({source})",
|
|
145
|
+
evidence=reason or "deterministic check re-run",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Restores the build-cycle signal for work done through the native-harness
|
|
149
|
+
# path (pcp build-plan + the Workflow tool's own agent()/parallel() -- see
|
|
150
|
+
# CLAUDE.md's Workflow/Agent/pcp-build split). That path marks a criterion
|
|
151
|
+
# done via `pcp verify` directly, never through build.py's own
|
|
152
|
+
# _build_one_criterion, which is the ONLY place telemetry.record() used to
|
|
153
|
+
# be called for build-cycle events. Nothing removed a hook; the hook was
|
|
154
|
+
# only ever written for the older headless-engine path, so telemetry.jsonl
|
|
155
|
+
# went silent the moment real work moved to the harness-driven one, even
|
|
156
|
+
# though decision_log.jsonl (recorded above) kept going.
|
|
157
|
+
from pcp import telemetry
|
|
158
|
+
telemetry.record(
|
|
159
|
+
pcp_dir, cycle="build", cycle_number=None,
|
|
160
|
+
module=module, submodule=None, criterion_id=criterion_id, files=[],
|
|
161
|
+
languages=[], lines_added=0, lines_removed=0,
|
|
162
|
+
result="pass", source=source,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
console.print(f"[green]✓[/green] {module}/{criterion_id} marked complete, verified_by={source}.")
|
|
166
|
+
console.print("[dim]Recorded in decision_log.jsonl. Run `pcp scan` to refresh current_state.md.[/dim]")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""pcp verify-syntax-fix — dry-run, non-destructive check: does a file's
|
|
2
|
+
current (working-tree) content differ from its git HEAD version by nothing
|
|
3
|
+
but YAML quote/escape characters?
|
|
4
|
+
|
|
5
|
+
Exists specifically for the gap this can't close by itself: an external
|
|
6
|
+
reviewer (a human, or another system's own permission layer) that needs to
|
|
7
|
+
verify a claim of "this is just a syntax fix" without either trusting the
|
|
8
|
+
claim on its face or being able to call into PCP's own internals directly.
|
|
9
|
+
This command gives that reviewer an independent, deterministic, scriptable
|
|
10
|
+
verdict (exit 0 = safe, exit 1 = unsafe) to check *before* any write is
|
|
11
|
+
attempted or approved, rather than after the fact.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import yaml
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
22
|
+
from pcp.commands.check import is_syntax_only_yaml_fix, _git_show_head
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command(name="verify-syntax-fix")
|
|
28
|
+
@click.argument("file_path", type=click.Path(exists=False))
|
|
29
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
30
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
31
|
+
def verify_syntax_fix(file_path: str, project_path: str | None):
|
|
32
|
+
"""Verify FILE_PATH's working-tree content differs from its git HEAD
|
|
33
|
+
version only by YAML quote/escape characters -- a deterministic
|
|
34
|
+
SAFE/UNSAFE verdict, not a trust-based claim. Exits 0 if safe, 1 if not.
|
|
35
|
+
|
|
36
|
+
FILE_PATH may be relative -- resolved against --path (or cwd) if so,
|
|
37
|
+
not against the process's own cwd."""
|
|
38
|
+
try:
|
|
39
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
40
|
+
except NoPCPDir as e:
|
|
41
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
42
|
+
sys.exit(2)
|
|
43
|
+
|
|
44
|
+
project_root = pcp_dir.parent
|
|
45
|
+
raw_target = Path(file_path)
|
|
46
|
+
target = (raw_target if raw_target.is_absolute() else project_root / raw_target).resolve()
|
|
47
|
+
if not target.is_file():
|
|
48
|
+
console.print(f"[red]Error:[/red] {file_path} does not exist")
|
|
49
|
+
sys.exit(2)
|
|
50
|
+
try:
|
|
51
|
+
rel_path = str(target.relative_to(project_root))
|
|
52
|
+
except ValueError:
|
|
53
|
+
console.print(f"[red]Error:[/red] {file_path} is not inside project root {project_root}")
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
|
|
56
|
+
new_text = target.read_text(errors="replace")
|
|
57
|
+
old_text = _git_show_head(project_root, rel_path)
|
|
58
|
+
|
|
59
|
+
if old_text is None:
|
|
60
|
+
console.print(f"[red]UNSAFE[/red] — {rel_path}: no git HEAD version found (new file — a real addition, not a syntax fix)")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
yaml.safe_load(new_text)
|
|
65
|
+
except yaml.YAMLError as e:
|
|
66
|
+
console.print(f"[red]UNSAFE[/red] — {rel_path}: new content does not parse as valid YAML: {e}")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
if is_syntax_only_yaml_fix(old_text, new_text):
|
|
70
|
+
console.print(f"[green]SAFE[/green] — {rel_path}: parses, and differs from HEAD only by quote/escape characters")
|
|
71
|
+
sys.exit(0)
|
|
72
|
+
|
|
73
|
+
console.print(f"[red]UNSAFE[/red] — {rel_path}: content differs beyond quoting/escaping — a real change, not a pure syntax fix")
|
|
74
|
+
sys.exit(1)
|