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,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)