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/commands/docs.py ADDED
@@ -0,0 +1,538 @@
1
+ """pcp docs — per-module doc kit: vision, BRD slice, current-built list, and
2
+ a chronological changelog that doubles as a drift ledger.
3
+
4
+ Pure aggregation over already-persisted sources (spec.yaml, acceptance.yaml,
5
+ brd_items.yaml, telemetry.jsonl, decision_log.jsonl, git history on the
6
+ module's own spec/acceptance files) — no LLM call. Same "one data source,
7
+ many views" convention as provenance.py/architecture_justification.py.
8
+
9
+ Drift-control framing, not just documentation: changelog.md merges build
10
+ completions with spec/acceptance.yaml git history into ONE chronological
11
+ timeline, so a spec change landing BETWEEN two criterion completions is
12
+ visible as a real drift signal — the module's declared intent moved while
13
+ it was mid-build — rather than buried in a separate `git log` nobody checks
14
+ against build history. bypass_log.yaml entries now carry a `modules` field
15
+ (check.py's `_attributed_modules`, added alongside this doc kit) computed
16
+ from staged-file → module-dir / criterion-target matching, so bypasses land
17
+ on this timeline too. Entries logged before that field existed have no
18
+ `modules` value and stay excluded — a known, stated limitation, not a
19
+ silent gap.
20
+ """
21
+
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ import click
29
+ import yaml
30
+ from rich.console import Console
31
+
32
+ from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
33
+ from pcp.schema.validator import load_yaml
34
+ from pcp import telemetry as telemetry_mod
35
+ from pcp import decision_log as decision_log_mod
36
+ from pcp.commands.build import _is_ui_facing_criterion
37
+ from pcp.commands.design_audit import _classify_rung, RUNG_LABEL
38
+
39
+ console = Console()
40
+
41
+ KIND_LABEL = {
42
+ "build": "🔨 built",
43
+ "spec_change": "📋 spec.yaml changed",
44
+ "acceptance_change": "📋 acceptance.yaml changed",
45
+ "decision": "💡 decision",
46
+ "bypass": "⚠ gate bypassed",
47
+ }
48
+
49
+
50
+ def _load_module_bypasses(pcp_dir: Path, module_name: str) -> list[dict]:
51
+ """Bypass entries attributed to this module via check.py's
52
+ `_attributed_modules`. Entries logged before that field existed carry no
53
+ `modules` key and are silently excluded — legacy data, not a bug."""
54
+ bypass_path = pcp_dir / "bypass_log.yaml"
55
+ if not bypass_path.exists():
56
+ return []
57
+ data = yaml.safe_load(bypass_path.read_text()) or {}
58
+ return [b for b in data.get("bypasses", []) if module_name in (b.get("modules") or [])]
59
+
60
+
61
+ def _normalize_to_utc_z(ts: str) -> str:
62
+ """git's %aI format carries the commit's local timezone offset (e.g.
63
+ +05:30), while telemetry.jsonl timestamps are always UTC `Z`. Comparing
64
+ the two as raw strings (this module's own timeline sort, and
65
+ _compute_drift_score's in-flight window check) silently misorders events
66
+ on any machine not in UTC — a +05:30 offset timestamp sorts as "later"
67
+ than a same-instant-or-earlier Z timestamp purely because '+' > digits
68
+ lexicographically. Normalize every git timestamp to Z before it enters
69
+ the timeline so all timestamps are directly, correctly comparable as
70
+ strings. Falls back to the raw value if parsing fails rather than
71
+ dropping the entry."""
72
+ try:
73
+ return datetime.fromisoformat(ts).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
74
+ except ValueError:
75
+ return ts
76
+
77
+
78
+ def _git_log_for_file(project_root: Path, path: Path) -> list[dict]:
79
+ """Chronological (oldest-first) commit history for one file — deterministic,
80
+ no LLM. Used to surface spec/acceptance.yaml changes as drift signals."""
81
+ if not path.exists():
82
+ return []
83
+ result = subprocess.run(
84
+ ["git", "log", "--follow", "--format=%H|%aI|%s", "--", str(path)],
85
+ cwd=project_root, capture_output=True, text=True,
86
+ )
87
+ if result.returncode != 0:
88
+ return []
89
+ entries = []
90
+ for line in reversed(result.stdout.strip().splitlines()):
91
+ if not line.strip():
92
+ continue
93
+ parts = line.split("|", 2)
94
+ if len(parts) != 3:
95
+ continue
96
+ entries.append({"commit": parts[0][:8], "timestamp": _normalize_to_utc_z(parts[1]), "subject": parts[2]})
97
+ return entries
98
+
99
+
100
+ def _module_dirs(pcp_dir: Path, module_name: str | None) -> list[Path]:
101
+ modules_dir = get_modules_dir(pcp_dir)
102
+ if not modules_dir.exists():
103
+ return []
104
+ if module_name:
105
+ d = modules_dir / module_name
106
+ return [d] if d.exists() else []
107
+ return sorted(p for p in modules_dir.iterdir() if p.is_dir())
108
+
109
+
110
+ def _brd_keywords(module_name: str, spec: dict) -> set[str]:
111
+ """Best-effort attribution — brd_items.yaml carries no `module` field
112
+ today (items are sourced from freeform session transcripts, see
113
+ capture.py). Cheap rung-1 keyword match, no LLM: module name variants
114
+ plus the description's own significant words."""
115
+ kws = {
116
+ module_name.lower(),
117
+ module_name.replace("-", " ").lower(),
118
+ module_name.replace("_", " ").lower(),
119
+ }
120
+ kws.update(re.findall(r"[a-zA-Z]{5,}", spec.get("description", "").lower())[:8])
121
+ return kws
122
+
123
+
124
+ def _keyword_match(text: str, keywords: set[str]) -> bool:
125
+ text_l = text.lower()
126
+ return any(kw in text_l for kw in keywords if kw)
127
+
128
+
129
+ def _specificity_rank(items: list[dict], keywords: set[str]) -> list[dict]:
130
+ """Specificity reordering (deterministic half of T-SimCSE's trick,
131
+ arXiv:2603.11800): a match on a RARE keyword (appears in few BRD items)
132
+ is stronger evidence of real attribution than a match on a word half the
133
+ items contain. Rank matched items by summed inverse-document-frequency of
134
+ their matched keywords — no embeddings, no LLM, pure counting; the
135
+ embedding-similarity half of the technique is deliberately not built
136
+ (PCP's zero-API-key architecture has no embedding provider)."""
137
+ if not items:
138
+ return items
139
+ texts = [str(i.get("description", "")).lower() for i in items]
140
+ doc_freq = {kw: sum(1 for t in texts if kw in t) for kw in keywords if kw}
141
+ n = len(items)
142
+
143
+ def score(idx: int) -> float:
144
+ t = texts[idx]
145
+ return sum((n / doc_freq[kw]) for kw in doc_freq if doc_freq[kw] and kw in t)
146
+
147
+ order = sorted(range(n), key=score, reverse=True)
148
+ return [items[i] for i in order]
149
+
150
+
151
+ def _compute_drift_score(timeline: list[dict], build_records: list[dict], bypass_count: int) -> dict:
152
+ """Promotes the visual-adjacency signal this doc kit already showed (a
153
+ human has to notice a `spec.yaml changed` entry sitting between two
154
+ `built` entries) into an explicit, computed flag — the exact next step
155
+ CLAUDE.md names for this doc kit. No new data source: everything here
156
+ is already present in this module's own timeline/telemetry.
157
+
158
+ Three signals, weighted (deliberately simple, not tuned against real
159
+ outcome data yet — phase 1, same honest framing as the rest of this doc
160
+ kit): in-flight spec/acceptance changes weigh heaviest since they're the
161
+ closest thing to a directly-observed drift event (the module's declared
162
+ intent moved while a build was still active); bypass count next (a gate
163
+ was overridden on this module's own files); retry count least (a
164
+ criterion can need extra attempts for reasons unrelated to drift, e.g.
165
+ a flaky test — correlated with instability, not proof of it)."""
166
+ build_timestamps = sorted(e["timestamp"] for e in timeline if e["kind"] == "build" and e.get("timestamp"))
167
+ in_flight: list[dict] = []
168
+ if len(build_timestamps) >= 2:
169
+ window_start, window_end = build_timestamps[0], build_timestamps[-1]
170
+ in_flight = [
171
+ e for e in timeline
172
+ if e["kind"] in ("spec_change", "acceptance_change")
173
+ and window_start < (e.get("timestamp") or "") < window_end
174
+ ]
175
+
176
+ retries_by_criterion: dict[str, int] = {}
177
+ for r in build_records:
178
+ cid = r.get("criterion_id")
179
+ if cid:
180
+ retries_by_criterion[cid] = retries_by_criterion.get(cid, 0) + 1
181
+ retry_count = sum(max(0, n - 1) for n in retries_by_criterion.values())
182
+
183
+ score = min(1.0, 0.5 * len(in_flight) + 0.2 * bypass_count + 0.1 * retry_count)
184
+ return {
185
+ "score": round(score, 2),
186
+ "in_flight_changes": in_flight,
187
+ "bypass_count": bypass_count,
188
+ "retry_count": retry_count,
189
+ }
190
+
191
+
192
+ def build_module_docs(pcp_dir: Path, module_dir: Path) -> dict:
193
+ """Pure aggregation for ONE module — everything the four rendered files
194
+ need. Callable at any point, no side effects besides read."""
195
+ project_root = pcp_dir.parent
196
+ module_name = module_dir.name
197
+ spec_path = module_dir / "spec.yaml"
198
+ acc_path = module_dir / "acceptance.yaml"
199
+ spec = load_yaml(spec_path) if spec_path.exists() else {}
200
+ acc = load_yaml(acc_path) if acc_path.exists() else {}
201
+ criteria = acc.get("criteria", [])
202
+
203
+ brd_path = pcp_dir / "brd_items.yaml"
204
+ all_brd_items = (yaml.safe_load(brd_path.read_text()) or {}).get("items", []) if brd_path.exists() else []
205
+ # Real link (capture.py stamps this at classification time, 2026-07-31)
206
+ # takes priority -- confirmed, no ranking needed. Items with no module
207
+ # field (pre-existing entries written before this, or program-level/
208
+ # ambiguous items the classifier deliberately left null) fall back to
209
+ # the keyword heuristic, ranked by specificity.
210
+ linked_brd = [i for i in all_brd_items if i.get("module") == module_name]
211
+ unlinked = [i for i in all_brd_items if not i.get("module")]
212
+ keywords = _brd_keywords(module_name, spec)
213
+ fuzzy_matched = _specificity_rank(
214
+ [i for i in unlinked if _keyword_match(i.get("description", ""), keywords)], keywords,
215
+ )
216
+ matched_brd = linked_brd + fuzzy_matched
217
+
218
+ telemetry_records = telemetry_mod.load(pcp_dir)
219
+ build_records = [
220
+ r for r in telemetry_records if r.get("cycle") == "build" and r.get("module") == module_name
221
+ ]
222
+ # Latest attempt per criterion — a status view, not a retry history
223
+ # (telemetry.jsonl/evidence/ already are that), same convention as
224
+ # dashboard.py's _qa_lookup.
225
+ latest_by_criterion: dict[str, dict] = {}
226
+ for r in build_records:
227
+ cid = r.get("criterion_id")
228
+ if not cid:
229
+ continue
230
+ if cid not in latest_by_criterion or r.get("timestamp", "") >= latest_by_criterion[cid].get("timestamp", ""):
231
+ latest_by_criterion[cid] = r
232
+
233
+ decisions = [d for d in decision_log_mod.load(pcp_dir) if d.get("module") == module_name]
234
+ spec_history = _git_log_for_file(project_root, spec_path)
235
+ acc_history = _git_log_for_file(project_root, acc_path)
236
+ bypasses = _load_module_bypasses(pcp_dir, module_name)
237
+
238
+ timeline: list[dict] = []
239
+ for cid, r in latest_by_criterion.items():
240
+ timeline.append({
241
+ "kind": "build", "timestamp": r.get("timestamp", ""),
242
+ "criterion_id": cid, "files": r.get("files") or [],
243
+ "lines_added": r.get("lines_added", 0), "lines_removed": r.get("lines_removed", 0),
244
+ })
245
+ for h in spec_history:
246
+ timeline.append({"kind": "spec_change", "timestamp": h["timestamp"], "commit": h["commit"], "subject": h["subject"]})
247
+ for h in acc_history:
248
+ timeline.append({"kind": "acceptance_change", "timestamp": h["timestamp"], "commit": h["commit"], "subject": h["subject"]})
249
+ for d in decisions:
250
+ timeline.append({"kind": "decision", "timestamp": d.get("timestamp", ""), "summary": d.get("summary", ""), "category": d.get("category", "")})
251
+ for b in bypasses:
252
+ timeline.append({
253
+ "kind": "bypass", "timestamp": b.get("timestamp", ""),
254
+ "reason": b.get("reason", ""), "files": b.get("files") or [],
255
+ })
256
+ timeline.sort(key=lambda e: e.get("timestamp") or "")
257
+ drift = _compute_drift_score(timeline, build_records, len(bypasses))
258
+
259
+ ui_criteria = [c for c in criteria if _is_ui_facing_criterion(c)]
260
+
261
+ # Best-effort BRD-vs-built cross-reference (deterministic keyword overlap,
262
+ # no LLM, same honesty posture as the BRD attribution above): brd_items.yaml
263
+ # only tracks active/superseded, nothing links a BRD item to the specific
264
+ # criterion that addressed it, so "addressed" here means "some COMPLETE
265
+ # criterion's description keyword-overlaps this item's description" --
266
+ # a starting point for human review, not a verified claim.
267
+ active_brd = [i for i in matched_brd if i.get("status") == "active"]
268
+ complete_criteria = [c for c in criteria if c.get("status") == "complete"]
269
+ brd_diff = []
270
+ for item in active_brd:
271
+ item_words = set(re.findall(r"[a-zA-Z]{5,}", item.get("description", "").lower()))
272
+ likely_matches = [
273
+ c["id"] for c in complete_criteria
274
+ if item_words & set(re.findall(r"[a-zA-Z]{5,}", c.get("description", "").lower()))
275
+ ]
276
+ brd_diff.append({"item": item, "likely_addressed_by": likely_matches})
277
+
278
+ return {
279
+ "module_name": module_name, "spec": spec, "criteria": criteria,
280
+ "matched_brd": matched_brd, "timeline": timeline, "bypass_count": len(bypasses),
281
+ "drift": drift, "ui_criteria": ui_criteria, "brd_diff": brd_diff,
282
+ }
283
+
284
+
285
+ def _render_vision(data: dict) -> str:
286
+ spec = data["spec"]
287
+ lines = [
288
+ f"# {data['module_name']} — Vision",
289
+ "",
290
+ "> Auto-generated by `pcp docs` from `spec.yaml` + this module's declared "
291
+ "objective coverage. Do not edit manually — edit `spec.yaml` (human-approved, "
292
+ "spec-immutable) instead.",
293
+ "",
294
+ "## What this module is for",
295
+ "",
296
+ spec.get("description", "_no description in spec.yaml_"),
297
+ "",
298
+ "## How it serves the program objective",
299
+ "",
300
+ ]
301
+ lines += [f"- {c}" for c in spec.get("objective_coverage", [])] or ["_none declared_"]
302
+ if spec.get("dependencies"):
303
+ lines += ["", "## Depends on", ""] + [f"- `{d}`" for d in spec["dependencies"]]
304
+ if spec.get("constraints"):
305
+ lines += ["", "## Constraints", ""] + [f"- {c}" for c in spec["constraints"]]
306
+ bvb = spec.get("build_vs_buy")
307
+ if bvb:
308
+ lines += ["", "## Build vs buy", "", f"**Decision:** `{bvb.get('decision', '')}`", "", bvb.get("rationale", "")]
309
+ return "\n".join(lines) + "\n"
310
+
311
+
312
+ def _render_brd(data: dict) -> str:
313
+ lines = [
314
+ f"# {data['module_name']} — Requirements Drift (BRD slice)",
315
+ "",
316
+ "> Auto-generated by `pcp docs`. Items `pcp capture` links to a real "
317
+ "module (2026-07-31 onward) are confirmed; items with no module "
318
+ "field (older entries, or program-level/ambiguous ones) fall back "
319
+ "to best-effort keyword match against `.pcp/brd_items.yaml`, which "
320
+ "can miss real matches or include unrelated ones. Treat the "
321
+ "fallback portion as a starting point, not ground truth.",
322
+ "",
323
+ ]
324
+ if not data["matched_brd"]:
325
+ lines.append("_No BRD items keyword-matched to this module yet._")
326
+ else:
327
+ for i in data["matched_brd"]:
328
+ lines.append(f"- **{i.get('id')}** ({i.get('status')}): {i.get('description')}")
329
+ if i.get("drift_flag"):
330
+ lines.append(f" - ⚠ drift flag: {i['drift_flag']}")
331
+ return "\n".join(lines) + "\n"
332
+
333
+
334
+ def _render_built(data: dict) -> str:
335
+ lines = [
336
+ f"# {data['module_name']} — Current Built List",
337
+ "",
338
+ "> Auto-generated by `pcp docs` from `acceptance.yaml`. Never edit manually.",
339
+ "",
340
+ "| ID | Description | Status | Check | Logic Tier | Build vs Buy |",
341
+ "|---|---|---|---|---|---|",
342
+ ]
343
+ for c in data["criteria"]:
344
+ bvb = (c.get("build_vs_buy") or {}).get("decision", "—")
345
+ lines.append(
346
+ f"| {c['id']} | {c['description']} | {c.get('status', 'pending')} | "
347
+ f"{c.get('check', 'manual')} | {c.get('logic_tier', '—')} | {bvb} |"
348
+ )
349
+ complete = sum(1 for c in data["criteria"] if c.get("status") == "complete")
350
+ lines += ["", f"**{complete}/{len(data['criteria'])} criteria complete.**"]
351
+ return "\n".join(lines) + "\n"
352
+
353
+
354
+ def _render_changelog(data: dict) -> str:
355
+ lines = [
356
+ f"# {data['module_name']} — Changelog / Drift Ledger",
357
+ "",
358
+ "> Auto-generated by `pcp docs`. Merges build completions "
359
+ "(`.pcp/telemetry.jsonl`), `spec.yaml`/`acceptance.yaml` git history, "
360
+ "attributed gate bypasses (`.pcp/bypass_log.yaml`), and distilled decisions "
361
+ "(`.pcp/decision_log.jsonl`) into one chronological timeline. A `spec.yaml "
362
+ "changed` entry landing BETWEEN two `built` entries is a real drift signal "
363
+ "— the module's declared intent moved while it was mid-build. Bypasses "
364
+ "logged before module-attribution existed have no `modules` field and are "
365
+ "excluded here — check `.pcp/bypass_log.yaml` directly for those.",
366
+ "",
367
+ ]
368
+
369
+ drift = data.get("drift", {})
370
+ lines += [
371
+ f"## Drift Score: {drift.get('score', 0):.2f}",
372
+ "",
373
+ "> Computed, not just visually adjacent: weighted from in-flight spec/"
374
+ "acceptance changes (0.5 each), attributed bypasses (0.2 each), and "
375
+ "criterion retries (0.1 each). Phase 1 — weights are deliberately "
376
+ "simple, not tuned against real outcome data yet.",
377
+ "",
378
+ f"- In-flight spec/acceptance changes: {len(drift.get('in_flight_changes', []))}",
379
+ f"- Attributed bypasses: {drift.get('bypass_count', 0)}",
380
+ f"- Criterion retries: {drift.get('retry_count', 0)}",
381
+ "",
382
+ ]
383
+ if drift.get("in_flight_changes"):
384
+ lines.append("**In-flight changes** (spec moved while this module was still mid-build):")
385
+ for e in drift["in_flight_changes"]:
386
+ lines.append(f"- `{e.get('timestamp', '?')}` `{e.get('commit', '')}` {e.get('subject', '')}")
387
+ lines.append("")
388
+
389
+ if not data["timeline"]:
390
+ lines.append("_No recorded activity for this module yet._")
391
+ return "\n".join(lines) + "\n"
392
+
393
+ for e in data["timeline"]:
394
+ ts = e.get("timestamp") or "?"
395
+ kind = KIND_LABEL.get(e["kind"], e["kind"])
396
+ if e["kind"] == "build":
397
+ files = ", ".join(e["files"][:5]) + (" …" if len(e["files"]) > 5 else "")
398
+ lines.append(f"- `{ts}` {kind} **{e['criterion_id']}** — +{e['lines_added']}/-{e['lines_removed']} lines ({files})")
399
+ elif e["kind"] in ("spec_change", "acceptance_change"):
400
+ lines.append(f"- `{ts}` {kind} — `{e['commit']}` {e['subject']}")
401
+ elif e["kind"] == "decision":
402
+ category = e.get("category") or "uncategorized"
403
+ lines.append(f"- `{ts}` {kind} ({category}) — {e['summary']}")
404
+ elif e["kind"] == "bypass":
405
+ files = ", ".join(e["files"][:5]) + (" …" if len(e["files"]) > 5 else "")
406
+ lines.append(f"- `{ts}` {kind} — {e['reason']} ({files})")
407
+ return "\n".join(lines) + "\n"
408
+
409
+
410
+ def _render_ui_ux(data: dict) -> str:
411
+ """Per-module UI/UX rollup -- forced into existence (always written,
412
+ even when empty) for any module with UI-facing criteria, rather than an
413
+ optional file a human has to remember to generate separately. Same
414
+ source data pcp design-audit already computes project-wide
415
+ (_classify_rung), just scoped to this one module for a quick local
416
+ reference instead of hunting through the project-wide rollup."""
417
+ ui_criteria = data["ui_criteria"]
418
+ lines = [
419
+ f"# {data['module_name']} — UI/UX",
420
+ "",
421
+ "> Auto-generated by `pcp docs`. Rolls up this module's UI-facing criteria: "
422
+ "screen archetype(s), building-block organisms, design_justification, nav "
423
+ "depth, and customization. Never edit manually -- edit the criterion's own "
424
+ "fields in acceptance.yaml instead.",
425
+ "",
426
+ ]
427
+ if not ui_criteria:
428
+ lines.append("_No UI-facing criteria in this module._")
429
+ return "\n".join(lines) + "\n"
430
+
431
+ undeclared = [c for c in ui_criteria if not c.get("design_justification")]
432
+ if undeclared:
433
+ # Deliberately no longer claims "Built, Hidden". A missing
434
+ # design_justification is a missing FIELD, not a hidden feature —
435
+ # conflating the two is what produced 101 phantom "hidden" criteria on
436
+ # Project O. Discoverability is measured from the built UI now
437
+ # (see nav_graph); this line reports declaration coverage and says so.
438
+ lines += [
439
+ f"**{len(undeclared)}/{len(ui_criteria)} UI-facing criteria declare no "
440
+ "`design_justification`** -- this is declaration coverage, not a "
441
+ "discoverability finding; see `pcp design-audit` for measured "
442
+ "reachability: "
443
+ + ", ".join(c["id"] for c in undeclared),
444
+ "",
445
+ ]
446
+
447
+ lines += ["| Criterion | Archetypes | Organisms | Rung | JTBD | Nav Depth | Customizable |",
448
+ "|---|---|---|---|---|---|---|"]
449
+ for c in ui_criteria:
450
+ dj = c.get("design_justification") or {}
451
+ # No nav analysis is threaded into this per-module rollup, so the
452
+ # classifier correctly returns None ("not measured") for every
453
+ # criterion. Rendering that as a rung would be inventing one.
454
+ rung = _classify_rung(c)
455
+ archetypes = ", ".join(c.get("screen_archetypes") or []) or "—"
456
+ organisms = ", ".join(c.get("ui_organisms") or []) or "—"
457
+ nav_depth = c.get("nav_depth") if c.get("nav_depth") is not None else "—"
458
+ customizable = "✓" if dj.get("customizable") else "—"
459
+ lines.append(
460
+ f"| {c['id']}: {c['description']} | {archetypes} | {organisms} | "
461
+ f"{f'{rung} ({RUNG_LABEL[rung]})' if rung is not None else '— (not measured)'} | "
462
+ f"{dj.get('jtbd_framing') or '—'} | "
463
+ f"{nav_depth} | {customizable} |"
464
+ )
465
+ return "\n".join(lines) + "\n"
466
+
467
+
468
+ def _render_module_diff(data: dict) -> str:
469
+ """Per-module diff: this module's active BRD items vs. what's actually
470
+ built, and the pending criteria gap. Deliberately does NOT claim to know
471
+ a BRD item was satisfied by a specific criterion -- brd_items.yaml has
472
+ no field linking the two, so "likely addressed by" is a best-effort
473
+ keyword-overlap hint (same honesty posture as brd.md's own attribution),
474
+ not a verified fact. The pending-criteria section, by contrast, is fully
475
+ deterministic -- straight from acceptance.yaml's own status field."""
476
+ lines = [
477
+ f"# {data['module_name']} — Diff (BRD vs Built)",
478
+ "",
479
+ "> Auto-generated by `pcp docs`. Compares this module's active BRD items "
480
+ "against completed acceptance criteria (best-effort keyword overlap -- "
481
+ "brd_items.yaml has no field linking a BRD item to the criterion that "
482
+ "addressed it, so this is a starting point for human review, not a "
483
+ "verified match) and lists pending criteria (deterministic).",
484
+ "",
485
+ "## Active BRD Items",
486
+ "",
487
+ ]
488
+ if not data["brd_diff"]:
489
+ lines.append("_No active BRD items keyword-matched to this module._")
490
+ else:
491
+ for entry in data["brd_diff"]:
492
+ item = entry["item"]
493
+ matches = entry["likely_addressed_by"]
494
+ status = f"likely addressed by: {', '.join(matches)}" if matches else "⚠ no completed criterion keyword-matches this item"
495
+ lines.append(f"- **{item.get('id')}**: {item.get('description')} — _{status}_")
496
+
497
+ lines += ["", "## Pending Acceptance Criteria", ""]
498
+ pending = [c for c in data["criteria"] if c.get("status") != "complete"]
499
+ if not pending:
500
+ lines.append("_None -- all acceptance criteria in this module are complete._")
501
+ else:
502
+ for c in pending:
503
+ lines.append(f"- **{c['id']}** ({c.get('status', 'pending')}): {c['description']}")
504
+ return "\n".join(lines) + "\n"
505
+
506
+
507
+ def write_module_docs(pcp_dir: Path, module_dir: Path) -> Path:
508
+ data = build_module_docs(pcp_dir, module_dir)
509
+ docs_dir = module_dir / "docs"
510
+ docs_dir.mkdir(exist_ok=True)
511
+ (docs_dir / "vision.md").write_text(_render_vision(data))
512
+ (docs_dir / "brd.md").write_text(_render_brd(data))
513
+ (docs_dir / "built.md").write_text(_render_built(data))
514
+ (docs_dir / "changelog.md").write_text(_render_changelog(data))
515
+ (docs_dir / "ui_ux.md").write_text(_render_ui_ux(data))
516
+ (docs_dir / "diff.md").write_text(_render_module_diff(data))
517
+ return docs_dir
518
+
519
+
520
+ @click.command()
521
+ @click.option("--module", "module_name", default=None, help="Generate docs for one module only.")
522
+ @click.option("--path", "project_path", type=click.Path(), default=None)
523
+ def docs(module_name: str | None, project_path: str | None):
524
+ """Per-module doc kit: vision, BRD slice, current-built list, changelog/drift ledger."""
525
+ try:
526
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
527
+ except NoPCPDir as e:
528
+ console.print(f"[red]Error:[/red] {e}")
529
+ sys.exit(2)
530
+
531
+ module_dirs = _module_dirs(pcp_dir, module_name)
532
+ if not module_dirs:
533
+ console.print(f"[yellow]No module(s) found{f' matching {module_name}' if module_name else ''}.[/yellow]")
534
+ sys.exit(0)
535
+
536
+ for module_dir in module_dirs:
537
+ out_dir = write_module_docs(pcp_dir, module_dir)
538
+ console.print(f"[green]✓[/green] {module_dir.name} -> {out_dir.relative_to(pcp_dir.parent)}/")