program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
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)}/")
|