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/orphaned_work.py ADDED
@@ -0,0 +1,262 @@
1
+ """Criteria whose work landed in the repo but whose status still says `pending`.
2
+
3
+ Three independent occurrences on Project O inside a week, via two
4
+ different code paths:
5
+
6
+ - **The wave-gate reopen path.** `core-data-model` A022/A030/A033/A038 — $30.04
7
+ spent, all four branches merged into `main`, all four set back to `pending`
8
+ because a wave gate blocked on findings about a module outside the wave. Fixed
9
+ 2026-07-30 (`_finding_blames_outside_wave`).
10
+ - **The run-stopped path, still unfixed.** `query-eval-harness`, 2026-07-30 —
11
+ $31.65 spent, A001 and A008 merged into `main` with source and tests present
12
+ (`32be3fa`, `bbb134c`), MOD_A002 committed (`4276c70`), and **all 18 criteria
13
+ reading `pending`**. No wave-block record exists; the run simply stopped after
14
+ its last commit and nothing wrote status back.
15
+
16
+ Whatever the cause, the consequence is the same and it is the worst kind PCP can
17
+ have: the project's own record of what is built becomes false in the direction
18
+ that makes people rebuild finished work. `pcp scan` regenerates `current_state.md`
19
+ from acceptance status, so a wrong status propagates into every downstream view —
20
+ the dashboard, `diff.md`, `validate-strategy`'s coverage, the next build's pending
21
+ list.
22
+
23
+ This module does not fix status. It **reports the contradiction**, deterministically
24
+ (rung 1, no LLM), because `acceptance.yaml` is human-approved and PCP must not
25
+ silently mark work complete on its own — that would trade a false "not built" for
26
+ a false "built", which is strictly worse.
27
+
28
+ The signal is a landed COMMIT written by PCP's own conventions -- `Merge
29
+ feat/<module>-<criterion_id>` from the merge path, or `<module>/<criterion_id>:`
30
+ from the auto-commit path. Branch-merged state is deliberately NOT used: worktree
31
+ reuse resets branches to the current base, so a branch that never carried a commit
32
+ still reports as merged. See `_landed_commit_subjects`. No heuristics about file
33
+ contents, no guessing.
34
+ """
35
+
36
+ import subprocess
37
+ from pathlib import Path
38
+
39
+ import yaml as _yaml
40
+
41
+ from pcp.schema.validator import load_yaml
42
+
43
+
44
+ def dump_yaml(data: dict) -> str:
45
+ return _yaml.dump(data, default_flow_style=False, sort_keys=False)
46
+
47
+
48
+ def _landed_commit_subjects(project_root: Path) -> list[str]:
49
+ """Commit subjects reachable from HEAD. Empty on any git failure.
50
+
51
+ `git branch --merged` is NOT a usable signal here and using it was the first
52
+ version of this module. `pcp build` reuses worktrees and resets their branch
53
+ to the current base (`_sync_worktree_to_base`), so a branch that never carried
54
+ a single commit still reports as "merged" — it is an ancestor of HEAD by
55
+ construction. Checked against Project O, that produced 18 findings of
56
+ which most were false: `feat/query-eval-harness-MOD_A001` and
57
+ `feat/core-data-model-A041` have no merge commit and never advanced.
58
+
59
+ The trustworthy signal is PCP's own commit conventions, which only exist when
60
+ work actually landed:
61
+ - `_merge_module_branch` commits `Merge feat/<module>-<criterion_id>`
62
+ - `_auto_commit_criterion` commits `<module>/<criterion_id>: <description>`
63
+ """
64
+ try:
65
+ proc = subprocess.run(
66
+ ["git", "log", "--format=%s", "HEAD"],
67
+ cwd=project_root, capture_output=True, text=True, timeout=60,
68
+ )
69
+ except (OSError, subprocess.SubprocessError):
70
+ return []
71
+ if proc.returncode != 0:
72
+ return []
73
+ return proc.stdout.splitlines()
74
+
75
+
76
+ def find_orphaned_work(pcp_dir: Path, project_root: Path | None = None) -> list[dict]:
77
+ """Pending criteria that have a landed commit written for them.
78
+
79
+ Deliberately conservative: requires a commit subject matching PCP's own
80
+ conventions exactly. A criterion whose work never produced such a commit is
81
+ treated as genuinely unfinished and is not reported — the first version used
82
+ branch-merged state instead and was wrong on 6 of 18 findings.
83
+ """
84
+ project_root = project_root or Path(pcp_dir).parent
85
+ subjects = _landed_commit_subjects(project_root)
86
+ if not subjects:
87
+ return []
88
+
89
+ found = []
90
+ modules_dir = Path(pcp_dir) / "strategy" / "modules"
91
+ if not modules_dir.exists():
92
+ return []
93
+ for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
94
+ module = acc_path.parent.name
95
+ try:
96
+ data = load_yaml(acc_path) or {}
97
+ except Exception:
98
+ continue
99
+ for c in data.get("criteria", []) or []:
100
+ if not isinstance(c, dict):
101
+ continue
102
+ status = c.get("status", "pending")
103
+ if status == "complete":
104
+ continue
105
+ cid = c.get("id")
106
+ branch = f"feat/{module}-{cid}"
107
+ merge_subject = f"Merge {branch}"
108
+ work_prefix = f"{module}/{cid}:"
109
+ evidence = next(
110
+ (s for s in subjects
111
+ if s.strip() == merge_subject or s.startswith(work_prefix)),
112
+ None,
113
+ )
114
+ if evidence:
115
+ found.append({
116
+ "module": module,
117
+ "criterion_id": cid,
118
+ "branch": branch,
119
+ "status": status,
120
+ "evidence": evidence[:120],
121
+ "description": (c.get("description") or "")[:120],
122
+ })
123
+ return found
124
+
125
+
126
+ def _unverified_now(modules_dir: Path) -> list[dict]:
127
+ found = []
128
+ for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
129
+ module = acc_path.parent.name
130
+ try:
131
+ data = load_yaml(acc_path) or {}
132
+ except Exception:
133
+ continue
134
+ for c in data.get("criteria", []) or []:
135
+ if not isinstance(c, dict):
136
+ continue
137
+ if c.get("status") == "complete" and not c.get("verified_by"):
138
+ found.append({
139
+ "module": module,
140
+ "criterion_id": c.get("id"),
141
+ "description": (c.get("description") or "")[:120],
142
+ })
143
+ return found
144
+
145
+
146
+ def _baseline_path(pcp_dir: Path) -> Path:
147
+ return Path(pcp_dir) / "unverified_complete_baseline.yaml"
148
+
149
+
150
+ def find_unverified_complete(pcp_dir: Path) -> list[dict]:
151
+ """Criteria marked `complete` with no `verified_by` -- the mirror image of
152
+ `find_orphaned_work` -- EXCLUDING a one-time grandfathered baseline.
153
+
154
+ `verified_by` is stamped ONLY by `_mark_criterion_complete` inside `pcp
155
+ build`'s real gated loop (or now `pcp verify`). Its absence on a `complete`
156
+ criterion means the status was hand-edited into acceptance.yaml directly --
157
+ no gate ever ran, no evidence exists, and PCP is reporting work as done that
158
+ it has no basis for.
159
+
160
+ First found live on Project O 2026-07-30: 12 criteria hand-flipped
161
+ after the orphaned-work fix landed, because no sanctioned "mark done" path
162
+ existed yet -- fixed by `pcp verify` in this same change.
163
+
164
+ **The baseline exists because the naive version was shipped and run against
165
+ the real project before the tests caught the problem it caused.** `verified_by`
166
+ is 6 days old (added 2026-07-24); Project O has 432 criteria going back
167
+ to early July. Un-baselined, this returns **282 of 333 complete criteria** --
168
+ 85% of all completed work, flagged as suspect for the sole reason that it
169
+ predates a field that did not exist when it was built. That is not a signal,
170
+ it is the field's own introduction date read back as an accusation. It is
171
+ also exactly the shape already named once this session: a check with no
172
+ sanctioned way to comply produces noise users learn to ignore (CTRL-018's 110
173
+ phantom blocks, 42.5% of that project's recorded blocks).
174
+
175
+ The baseline is a one-time snapshot, written to
176
+ `.pcp/unverified_complete_baseline.yaml` on the first call ever made in a
177
+ project (empty findings that call, honestly -- there is no "new" yet to
178
+ report). Every criterion in it is accepted debt from before this check
179
+ existed, and stays grandfathered PERMANENTLY by (module, criterion_id)
180
+ identity -- not by status. A baselined criterion never reappears just from
181
+ cycling pending -> complete again, deliberately: acceptance.yaml is already
182
+ gated behind `pcp pm`/`pcp verify`/human approval, so resurrecting it on a
183
+ status bounce would add a new failure mode (an innocent status edit making
184
+ old debt look like a new violation) without closing any real hole. The way
185
+ out of the baseline is `pcp verify`, which stamps `verified_by` and removes
186
+ the criterion from the unverified set entirely -- not silence, an actual fix.
187
+ Anything unverified-complete that was never in the baseline is new since
188
+ this check started watching, and is reported every time.
189
+
190
+ This is the more dangerous direction of the two orphaned-work checks. A false
191
+ `pending` costs a wasted rebuild. A false `complete` means the work is never
192
+ checked again -- silently absent from every future gate, audit, and coverage
193
+ figure. Advisory only, never blocking, for the same reason CTRL-018 is warn
194
+ by default: refusing to build over inherited debt would be the wrong failure
195
+ mode."""
196
+ modules_dir = Path(pcp_dir) / "strategy" / "modules"
197
+ if not modules_dir.exists():
198
+ return []
199
+ current = _unverified_now(modules_dir)
200
+
201
+ baseline_path = _baseline_path(pcp_dir)
202
+ if not baseline_path.exists():
203
+ keys = sorted(f"{f['module']}/{f['criterion_id']}" for f in current)
204
+ try:
205
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
206
+ baseline_path.write_text(dump_yaml({
207
+ "note": ("One-time grandfather snapshot, written automatically by the first "
208
+ "`pcp scan`/`pcp build` after this check shipped. Everything listed here "
209
+ "predates verified_by tracking and is accepted debt, not a new problem -- "
210
+ "see find_unverified_complete's docstring. Never hand-edit; a criterion "
211
+ "drops out of this list on its own once it goes through `pcp verify` or "
212
+ "`pcp build`."),
213
+ "criteria": keys,
214
+ }))
215
+ except OSError:
216
+ pass # best-effort -- an unwritable .pcp/ must not break scan/build
217
+ return []
218
+
219
+ try:
220
+ baseline = set((load_yaml(baseline_path) or {}).get("criteria") or [])
221
+ except Exception:
222
+ baseline = set()
223
+ return [f for f in current if f"{f['module']}/{f['criterion_id']}" not in baseline]
224
+
225
+
226
+ def format_unverified_findings(found: list[dict]) -> list[str]:
227
+ """Human-readable lines for find_unverified_complete. Empty when nothing to say."""
228
+ if not found:
229
+ return []
230
+ lines = [
231
+ f"{len(found)} criterion(s) marked 'complete' with no verified_by — no gate ever "
232
+ f"confirmed this, the status was set by hand:"
233
+ ]
234
+ for f in found[:12]:
235
+ lines.append(f" {f['module']}/{f['criterion_id']} <- fix: pcp verify {f['module']} {f['criterion_id']}")
236
+ if len(found) > 12:
237
+ lines.append(f" ... and {len(found) - 12} more")
238
+ lines.append(
239
+ "If genuinely done, record it properly with the command above. Leaving it as-is means "
240
+ "no gate, audit, or coverage figure will ever check this again."
241
+ )
242
+ return lines
243
+
244
+
245
+ def format_findings(found: list[dict]) -> list[str]:
246
+ """Human-readable lines. Empty list when there is nothing to say."""
247
+ if not found:
248
+ return []
249
+ lines = [
250
+ f"{len(found)} criterion(s) marked '{found[0]['status']}' or pending whose work is "
251
+ f"already merged into this branch — the status is stale, not the code:"
252
+ ]
253
+ for f in found[:12]:
254
+ lines.append(f" {f['module']}/{f['criterion_id']} <- commit: {f['evidence']}")
255
+ if len(found) > 12:
256
+ lines.append(f" ... and {len(found) - 12} more")
257
+ lines.append(
258
+ "Verify, then mark them complete via `pcp pm` — acceptance.yaml is human-approved, "
259
+ "so PCP will not flip status on its own. Leaving it stale makes the next build "
260
+ "redo finished work."
261
+ )
262
+ return lines
pcp/pcp_dir.py ADDED
@@ -0,0 +1,35 @@
1
+ """Locate and navigate the .pcp/ directory for a project."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class NoPCPDir(Exception):
7
+ pass
8
+
9
+
10
+ def find_pcp_dir(start: Path | None = None) -> Path:
11
+ """Walk up from start (default cwd) to find .pcp/. Raises NoPCPDir if not found."""
12
+ current = (start or Path.cwd()).resolve()
13
+ for parent in [current, *current.parents]:
14
+ candidate = parent / ".pcp"
15
+ if candidate.is_dir():
16
+ return candidate
17
+ raise NoPCPDir(
18
+ "No .pcp/ directory found. Run `pcp init` to initialise this project."
19
+ )
20
+
21
+
22
+ def get_modules_dir(pcp_dir: Path) -> Path:
23
+ return pcp_dir / "strategy" / "modules"
24
+
25
+
26
+ def get_objective(pcp_dir: Path) -> Path:
27
+ return pcp_dir / "objective.md"
28
+
29
+
30
+ def get_decomposition(pcp_dir: Path) -> Path:
31
+ return pcp_dir / "strategy" / "decomposition.md"
32
+
33
+
34
+ def get_ontology_state(pcp_dir: Path) -> Path:
35
+ return pcp_dir / "ontology_state.yaml"
pcp/pcp_status.py ADDED
@@ -0,0 +1,313 @@
1
+ """Shared pcp.md governance snapshot writer.
2
+
3
+ Called by `pcp scan` (auto) and `pcp status` (on-demand).
4
+ pcp.md lives at project root — never inside .pcp/.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import re
9
+ import yaml
10
+
11
+
12
+ def _read_optional(path: Path, max_chars: int = 600) -> str:
13
+ if not path.exists():
14
+ return ""
15
+ text = path.read_text().strip()
16
+ return text[:max_chars] + ("\n…" if len(text) > max_chars else "")
17
+
18
+
19
+ def _evaluate_exit_criterion(c: dict, project_root: Path) -> bool:
20
+ check = c.get("check", "manual")
21
+ if check == "file_exists":
22
+ return (project_root / c.get("target", "")).exists()
23
+ if check == "ast_pattern":
24
+ target = project_root / c.get("target", "")
25
+ pattern = c.get("pattern", "")
26
+ if not target.exists() or not pattern:
27
+ return c.get("status") == "complete"
28
+ import re
29
+ return bool(re.search(pattern, target.read_text(errors="replace"), re.MULTILINE))
30
+ return c.get("status") == "complete"
31
+
32
+
33
+ def _extract_phase(pcp_dir: Path) -> tuple[str, list[dict]]:
34
+ sdlc_path = pcp_dir / "SDLC_phase.yaml"
35
+ if not sdlc_path.exists():
36
+ return "unknown", []
37
+ data = yaml.safe_load(sdlc_path.read_text()) or {}
38
+ current = data.get("current_phase", "unknown")
39
+ phases = data.get("phases", [])
40
+ current_phase_data = next((p for p in phases if p["name"] == current), {})
41
+ project_root = pcp_dir.parent
42
+ criteria = current_phase_data.get("exit_criteria", [])
43
+ # Evaluate deterministic checks live
44
+ evaluated = []
45
+ for c in criteria:
46
+ done = _evaluate_exit_criterion(c, project_root)
47
+ evaluated.append({**c, "_done": done})
48
+ return current, evaluated
49
+
50
+
51
+ def _extract_bypass_count(pcp_dir: Path) -> int:
52
+ log_path = pcp_dir / "bypass_log.yaml"
53
+ if not log_path.exists():
54
+ return 0
55
+ data = yaml.safe_load(log_path.read_text()) or {}
56
+ return len(data.get("bypasses", []))
57
+
58
+
59
+ def _extract_audit_summary(pcp_dir: Path) -> str | None:
60
+ audit_path = pcp_dir / "audit.md"
61
+ if not audit_path.exists():
62
+ return None
63
+ generated, findings = "", None
64
+ for line in audit_path.read_text().splitlines():
65
+ if line.startswith("Generated:"):
66
+ generated = line.split(":", 1)[1].strip()
67
+ elif line.startswith("Findings:"):
68
+ findings = line.split(":", 1)[1].strip()
69
+ if findings is None:
70
+ return f"_Last run {generated} — no audit tool detected._" if generated else None
71
+ return f"{findings} dead-code finding(s) — last run {generated}"
72
+
73
+
74
+ def _extract_token_summary(pcp_dir: Path) -> str | None:
75
+ ledger_path = pcp_dir / "token_ledger.yaml"
76
+ if not ledger_path.exists():
77
+ return None
78
+ data = yaml.safe_load(ledger_path.read_text()) or {}
79
+ calls = data.get("calls", [])
80
+ if not calls:
81
+ return None
82
+ total_input = sum(c.get("input_tokens", 0) + c.get("cache_creation_tokens", 0) for c in calls)
83
+ total_cache_read = sum(c.get("cache_read_tokens", 0) for c in calls)
84
+ total_output = sum(c.get("output_tokens", 0) for c in calls)
85
+ total_cost = sum(c.get("cost_usd") or 0 for c in calls)
86
+ by_model = {}
87
+ for c in calls:
88
+ m = c.get("model", "default")
89
+ by_model[m] = by_model.get(m, 0) + 1
90
+ model_breakdown = ", ".join(f"{m}:{n}" for m, n in sorted(by_model.items()))
91
+ return (
92
+ f"{len(calls)} LLM call(s) — {total_input:,} input / {total_cache_read:,} cache-read / "
93
+ f"{total_output:,} output tokens — ~${total_cost:.2f} — by model: {model_breakdown}"
94
+ )
95
+
96
+
97
+ def _extract_test_coverage(pcp_dir: Path) -> str | None:
98
+ cs = pcp_dir / "current_state.md"
99
+ if not cs.exists():
100
+ return None
101
+ text = cs.read_text()
102
+ m = re.search(r"## Test Coverage\n([\d.]+)% \(([^)]+)\)", text)
103
+ return f"{m.group(1)}% ({m.group(2)})" if m else None
104
+
105
+
106
+ def _extract_telemetry_summary(pcp_dir: Path) -> str | None:
107
+ from pcp import telemetry
108
+ records = telemetry.load(pcp_dir)
109
+ if not records:
110
+ return None
111
+ agg = telemetry.aggregate(records)
112
+ by_module = agg["by_module"]
113
+ if not by_module:
114
+ return None
115
+ total_qa = sum(v["qa_total"] for v in by_module.values())
116
+ total_blocks = sum(v["qa_blocks"] for v in by_module.values())
117
+ total_attempts = len(agg["build_records"])
118
+ total_criteria = len({(m, c) for m, v in by_module.items() for c in v["criteria"]})
119
+ avg_attempts = total_attempts / total_criteria if total_criteria else 0.0
120
+ qa_rate = f"{total_blocks}/{total_qa}" if total_qa else "—"
121
+ worst_module = max(
122
+ by_module.items(),
123
+ key=lambda kv: (kv[1]["attempts"] / (len(kv[1]["criteria"]) or 1)),
124
+ default=None,
125
+ )
126
+ worst_note = ""
127
+ if worst_module and len(by_module) > 1:
128
+ wname, wv = worst_module
129
+ wavg = wv["attempts"] / (len(wv["criteria"]) or 1)
130
+ if wavg > avg_attempts:
131
+ worst_note = f" — highest retry rate: `{wname}` ({wavg:.1f} attempts/criterion)"
132
+ return (
133
+ f"{total_criteria} criteria built, {avg_attempts:.1f} avg attempts/criterion, "
134
+ f"QA blocks {qa_rate}{worst_note}. Run `pcp telemetry` for the per-module breakdown."
135
+ )
136
+
137
+
138
+ def _extract_brd_summary(pcp_dir: Path) -> str | None:
139
+ brd_path = pcp_dir / "brd.md"
140
+ if not brd_path.exists():
141
+ return None
142
+ items_path = pcp_dir / "brd_items.yaml"
143
+ items = (yaml.safe_load(items_path.read_text()) or {}).get("items", []) if items_path.exists() else []
144
+ active = [i for i in items if i.get("status") == "active"]
145
+ drift = [i for i in active if i.get("drift_flag")]
146
+ if not active:
147
+ return None
148
+ note = f", {len(drift)} drift flag(s) vs objective.md" if drift else ""
149
+ return f"{len(active)} active requirement(s){note}. See `brd.md`."
150
+
151
+
152
+ def _extract_decision_log_summary(pcp_dir: Path) -> str | None:
153
+ from pcp import decision_log
154
+ records = decision_log.load(pcp_dir)
155
+ if not records:
156
+ return None
157
+ agg = decision_log.aggregate(records)
158
+ by_category = agg["by_category"]
159
+ breakdown = ", ".join(f"{cat}:{len(items)}" for cat, items in sorted(by_category.items()))
160
+ return f"{len(records)} technical decision(s) captured — {breakdown}. Run `pcp telemetry` or see `.pcp/decision_log.jsonl`."
161
+
162
+
163
+ def _extract_pending_gaps(pcp_dir: Path) -> list[str]:
164
+ cs = pcp_dir / "current_state.md"
165
+ if not cs.exists():
166
+ return []
167
+ gaps = []
168
+ for line in cs.read_text().splitlines():
169
+ if re.match(r"\s*- \[ \]", line):
170
+ gaps.append(line.strip()[6:])
171
+ return gaps
172
+
173
+
174
+ def _module_table(modules_results: list[dict]) -> list[str]:
175
+ lines = [
176
+ "| Module | Criteria | Complete | Pending |",
177
+ "|---|---|---|---|",
178
+ ]
179
+ for m in modules_results:
180
+ total = len(m["criteria"])
181
+ complete = sum(1 for c in m["criteria"] if c["status"] == "complete")
182
+ pending = total - complete
183
+ status = "✓" if pending == 0 else f"{pending} pending"
184
+ lines.append(f"| `{m['module']}` | {total} | {complete} | {status} |")
185
+ return lines
186
+
187
+
188
+ def _phase_exit_table(exit_criteria: list[dict]) -> list[str]:
189
+ lines = []
190
+ for c in exit_criteria:
191
+ done = c.get("_done", c.get("status") == "complete")
192
+ mark = "x" if done else " "
193
+ lines.append(f"- [{mark}] **{c['id']}**: {c['description']}")
194
+ return lines or ["_No exit criteria defined._"]
195
+
196
+
197
+ def extract_objective_text(pcp_dir: Path) -> str:
198
+ """First non-heading body text from objective.md. Real bug, found
199
+ 2026-07-08: a heading immediately followed by its body text on the very
200
+ next line (no blank line between them -- completely normal markdown,
201
+ e.g. "## Why This Exists\nBecause...") used to make the WHOLE block get
202
+ rejected outright (the paragraph, heading+body glued together, "starts
203
+ with #"), silently discarding real objective text and falling through
204
+ to "No objective.md found" even though the file existed and had real
205
+ content. Strips leading heading line(s) off each paragraph block first,
206
+ and only rejects a block if nothing but headings remain. Factored out
207
+ of write_pcp_md so dashboard.py can reuse the same extraction instead
208
+ of duplicating this parsing."""
209
+ obj_path = pcp_dir / "objective.md"
210
+ if not obj_path.exists():
211
+ return ""
212
+ raw = obj_path.read_text()
213
+ for block in re.split(r"\n{2,}", raw):
214
+ lines = block.strip().split("\n")
215
+ while lines and lines[0].strip().startswith("#"):
216
+ lines.pop(0)
217
+ body = "\n".join(lines).strip()
218
+ if body:
219
+ return body[:500]
220
+ return ""
221
+
222
+
223
+ def write_pcp_md(
224
+ pcp_dir: Path,
225
+ modules_results: list[dict],
226
+ timestamp: str,
227
+ total: int,
228
+ complete: int,
229
+ ) -> Path:
230
+ """Write pcp.md to project root. Returns path."""
231
+ project_root = pcp_dir.parent
232
+ project_name = project_root.name
233
+ score = complete / total if total else 0.0
234
+ score_pct = f"{score:.0%}"
235
+
236
+ phase_name, exit_criteria = _extract_phase(pcp_dir)
237
+ bypass_count = _extract_bypass_count(pcp_dir)
238
+ pending_gaps = _extract_pending_gaps(pcp_dir)
239
+ audit_summary = _extract_audit_summary(pcp_dir)
240
+ token_summary = _extract_token_summary(pcp_dir)
241
+ telemetry_summary = _extract_telemetry_summary(pcp_dir)
242
+ test_coverage = _extract_test_coverage(pcp_dir)
243
+ brd_summary = _extract_brd_summary(pcp_dir)
244
+ decision_log_summary = _extract_decision_log_summary(pcp_dir)
245
+ obj_text = extract_objective_text(pcp_dir)
246
+
247
+ lines = [
248
+ f"# PCP Governance — {project_name}",
249
+ f"_Updated: {timestamp} | Phase: `{phase_name}` | Coverage: {score_pct} | Bypasses: {bypass_count}_",
250
+ "",
251
+ "> Auto-generated by `pcp scan`. Do not edit manually.",
252
+ "",
253
+ "## Objective",
254
+ "",
255
+ obj_text or "_No objective.md found. Run `pcp init`._",
256
+ "",
257
+ f"## SDLC Phase: `{phase_name}`",
258
+ "",
259
+ *_phase_exit_table(exit_criteria),
260
+ "",
261
+ "## Module Status",
262
+ "",
263
+ *_module_table(modules_results),
264
+ "",
265
+ ]
266
+
267
+ if pending_gaps:
268
+ lines += [
269
+ "## Pending Gaps",
270
+ "",
271
+ *[f"- [ ] {g}" for g in pending_gaps],
272
+ "",
273
+ ]
274
+ else:
275
+ lines += [
276
+ "## Pending Gaps",
277
+ "",
278
+ "_All acceptance criteria met._",
279
+ "",
280
+ ]
281
+
282
+ if test_coverage:
283
+ lines += ["## Test Coverage", "", test_coverage, ""]
284
+
285
+ if audit_summary:
286
+ lines += ["## Dead Code / Bloat", "", audit_summary, ""]
287
+
288
+ if token_summary:
289
+ lines += ["## Token Spend", "", token_summary, ""]
290
+
291
+ if telemetry_summary:
292
+ lines += ["## Build Efficiency", "", telemetry_summary, ""]
293
+
294
+ if brd_summary:
295
+ lines += ["## Business Requirements (Living)", "", brd_summary, ""]
296
+
297
+ if decision_log_summary:
298
+ lines += ["## Technical Decisions", "", decision_log_summary, ""]
299
+
300
+ if bypass_count > 0:
301
+ log_path = pcp_dir / "bypass_log.yaml"
302
+ data = yaml.safe_load(log_path.read_text()) or {}
303
+ bypasses = data.get("bypasses", [])
304
+ lines += ["## Bypass Log", ""]
305
+ for b in bypasses[-5:]: # last 5 only
306
+ lines.append(f"- `{b.get('timestamp', '')}` — {b.get('reason', '')} (rules: {', '.join(b.get('rules_bypassed', []))})")
307
+ lines.append("")
308
+ else:
309
+ lines += ["## Bypass Log", "", "_No bypasses._", ""]
310
+
311
+ out = project_root / "pcp.md"
312
+ out.write_text("\n".join(lines))
313
+ return out