program-context-protocol 0.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
@@ -0,0 +1,732 @@
1
+ """pcp dashboard — a static, self-contained HTML status dashboard generated
2
+ from .pcp/ state: module status (grouped by dependency wave), SDLC phase/
3
+ milestone progress, build efficiency, evidence-chain integrity, and recent
4
+ drift/decision activity.
5
+
6
+ Reuses the same data pcp_status.py's pcp.md snapshot already computes —
7
+ this is a visual rendering of the same underlying state, not a second
8
+ source of truth. No server, no JS framework, no external font/asset CDN
9
+ (the whole point is one file that works offline, opened by any team member
10
+ who has the repo checked out — individual and team use alike, since a
11
+ shared team project's .pcp/ state is the same file everyone's `pcp
12
+ dashboard` run reads from after a `git pull`).
13
+
14
+ Written to <project_root>/dashboard.html — same location convention as
15
+ pcp.md (project root, never inside .pcp/, so it's easy to find and open).
16
+ """
17
+
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from html import escape
21
+ from pathlib import Path
22
+
23
+ import click
24
+ import yaml
25
+ from rich.console import Console
26
+
27
+ from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
28
+ from pcp.schema.validator import load_yaml
29
+
30
+ console = Console()
31
+
32
+ STATUS_LABEL = {"complete": "Complete", "in_progress": "In Progress", "pending": "Pending"}
33
+
34
+
35
+ def _module_status(complete: int, total: int) -> str:
36
+ if total > 0 and complete == total:
37
+ return "complete"
38
+ if complete > 0:
39
+ return "in_progress"
40
+ return "pending"
41
+
42
+
43
+ def build_dashboard_data(pcp_dir: Path) -> dict:
44
+ """Pure aggregation — safe to call at any point, no side effects besides
45
+ the reads `pcp_status.py`'s own helpers already do."""
46
+ from pcp.commands.scan import _scan_module, _load_prior_manual_status
47
+ from pcp.commands.build import compute_waves
48
+ from pcp.commands.provenance import _check_chain_integrity, build_provenance
49
+ from pcp.commands.architecture_justification import build_architecture_justification
50
+ from pcp.commands.design_audit import build_design_audit
51
+ from pcp import pcp_status as ps
52
+
53
+ project_root = pcp_dir.parent
54
+ modules_dir = get_modules_dir(pcp_dir)
55
+ prior_manual = _load_prior_manual_status(pcp_dir / "current_state.md")
56
+
57
+ qa_by_criterion, wave_gates = _qa_lookup(pcp_dir)
58
+
59
+ acceptance_files = sorted(modules_dir.glob("*/acceptance.yaml")) if modules_dir.exists() else []
60
+ modules_for_waves = []
61
+ modules = []
62
+ for af in acceptance_files:
63
+ module_name = af.parent.name
64
+ spec_path = af.parent / "spec.yaml"
65
+ spec = load_yaml(spec_path) if spec_path.exists() else {}
66
+ result = _scan_module(module_name, af, project_root, prior_manual)
67
+ modules_for_waves.append({"name": module_name, "spec": spec})
68
+ for c in result["criteria"]:
69
+ c["qa"] = qa_by_criterion.get((module_name, c["id"]), {})
70
+ total = len(result["criteria"])
71
+ complete = sum(1 for c in result["criteria"] if c["status"] == "complete")
72
+ modules.append({
73
+ "name": module_name,
74
+ "dependencies": spec.get("dependencies") or [],
75
+ "total": total, "complete": complete, "pending": total - complete,
76
+ "status": _module_status(complete, total),
77
+ "criteria": result["criteria"],
78
+ })
79
+
80
+ wave_of = compute_waves(modules_for_waves) if modules_for_waves else {}
81
+ for m in modules:
82
+ m["wave"] = wave_of.get(m["name"], 0)
83
+ num_waves = max((m["wave"] for m in modules), default=-1) + 1
84
+
85
+ total = sum(m["total"] for m in modules)
86
+ complete = sum(m["complete"] for m in modules)
87
+ score = complete / total if total else 0.0
88
+
89
+ phase_name, _ = ps._extract_phase(pcp_dir)
90
+ sdlc_path = pcp_dir / "SDLC_phase.yaml"
91
+ phases = []
92
+ if sdlc_path.exists():
93
+ sdlc_data = yaml.safe_load(sdlc_path.read_text()) or {}
94
+ for p in sdlc_data.get("phases", []):
95
+ crits = p.get("exit_criteria", [])
96
+ evaluated = [{**c, "_done": ps._evaluate_exit_criterion(c, project_root)} for c in crits]
97
+ done = sum(1 for c in evaluated if c["_done"])
98
+ phases.append({
99
+ "name": p["name"], "is_current": p["name"] == phase_name,
100
+ "exit_criteria": evaluated, "done": done, "total": len(evaluated),
101
+ })
102
+
103
+ bypass_count = ps._extract_bypass_count(pcp_dir)
104
+ recent_bypasses = []
105
+ bypass_log_path = pcp_dir / "bypass_log.yaml"
106
+ if bypass_log_path.exists():
107
+ data = yaml.safe_load(bypass_log_path.read_text()) or {}
108
+ recent_bypasses = (data.get("bypasses") or [])[-5:]
109
+
110
+ return {
111
+ "project_name": project_root.name,
112
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
113
+ "phase_name": phase_name,
114
+ "phases": phases,
115
+ "modules": modules,
116
+ "num_waves": num_waves,
117
+ "total": total, "complete": complete, "score_pct": score,
118
+ "bypass_count": bypass_count,
119
+ "recent_bypasses": recent_bypasses,
120
+ "test_coverage": ps._extract_test_coverage(pcp_dir),
121
+ "token_summary": ps._extract_token_summary(pcp_dir),
122
+ "telemetry_summary": ps._extract_telemetry_summary(pcp_dir),
123
+ "brd_summary": ps._extract_brd_summary(pcp_dir),
124
+ "decision_log_summary": ps._extract_decision_log_summary(pcp_dir),
125
+ "chain_integrity": _check_chain_integrity(pcp_dir),
126
+ "wave_gates": wave_gates,
127
+ "objective_text": ps.extract_objective_text(pcp_dir),
128
+ "pending_gaps": ps._extract_pending_gaps(pcp_dir),
129
+ "provenance": build_provenance(pcp_dir),
130
+ "architecture_justification": build_architecture_justification(pcp_dir),
131
+ "design_audit": build_design_audit(pcp_dir),
132
+ }
133
+
134
+
135
+ CHECK_LABEL = {
136
+ "layer1": "Layer 1", "test-suite": "Tests", "lint": "Lint", "sast": "SAST",
137
+ "architect-review": "Architect", "gate": "PR Gate", "escalation": "Escalation",
138
+ "wave-contract": "Contract", "wave-test-suite": "Wave Tests",
139
+ "wave-validate-strategy": "Wave Strategy", "wave-architect-review": "Wave Architect",
140
+ }
141
+
142
+
143
+ def _qa_lookup(pcp_dir: Path) -> tuple[dict, list]:
144
+ """Returns ((module, criterion_id) -> {check: latest_record}, wave-level
145
+ gate records) from telemetry.jsonl. Keeps only the latest record per
146
+ (criterion, check) or per wave-level check — the dashboard shows current
147
+ QA status, not the full retry history (that's what .pcp/evidence/ and
148
+ telemetry.jsonl itself are for)."""
149
+ from pcp import telemetry
150
+
151
+ per_criterion: dict[tuple, dict[str, dict]] = {}
152
+ wave_latest: dict[str, dict] = {}
153
+ for r in telemetry.load(pcp_dir):
154
+ if r.get("cycle") != "qa":
155
+ continue
156
+ check = r.get("check")
157
+ if not check:
158
+ continue
159
+ ts = r.get("timestamp", "")
160
+ if check.startswith("wave-"):
161
+ existing = wave_latest.get(check)
162
+ if not existing or ts >= existing.get("timestamp", ""):
163
+ wave_latest[check] = r
164
+ continue
165
+ module, criterion_id = r.get("module"), r.get("criterion_id")
166
+ if not module or not criterion_id:
167
+ continue
168
+ checks = per_criterion.setdefault((module, criterion_id), {})
169
+ existing = checks.get(check)
170
+ if not existing or ts >= existing.get("timestamp", ""):
171
+ checks[check] = r
172
+
173
+ wave_gates = sorted(wave_latest.values(), key=lambda r: r.get("check", ""))
174
+ return per_criterion, wave_gates
175
+
176
+
177
+ CSS = """
178
+ :root {
179
+ --bg: #faf8f4; --surface: #ffffff; --ink: #1c1917; --ink-dim: #6b645c;
180
+ --border: #e5e0d8; --accent: #0f6e70; --accent-soft: #e4f1f0;
181
+ --complete: #2e8b57; --complete-soft: #e5f3ea;
182
+ --progress: #b8790f; --progress-soft: #fbf0dc;
183
+ --blocked: #b8383f; --blocked-soft: #fbe9e9;
184
+ --pending: #8a8478; --pending-soft: #f1efe9;
185
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, monospace;
186
+ --sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, sans-serif;
187
+ }
188
+ @media (prefers-color-scheme: dark) {
189
+ :root {
190
+ --bg: #161513; --surface: #201f1c; --ink: #ede8e0; --ink-dim: #9a9288;
191
+ --border: #38352f; --accent: #4fc3c6; --accent-soft: #1c3536;
192
+ --complete: #5cbf87; --complete-soft: #1c3427;
193
+ --progress: #e0a83f; --progress-soft: #3a2e12;
194
+ --blocked: #e2696f; --blocked-soft: #3a1e1f;
195
+ --pending: #948d80; --pending-soft: #2a2822;
196
+ }
197
+ }
198
+ * { box-sizing: border-box; }
199
+ body {
200
+ margin: 0; padding: 2.5rem clamp(1rem, 4vw, 3rem); background: var(--bg); color: var(--ink);
201
+ font-family: var(--sans); line-height: 1.5; font-size: 15px;
202
+ }
203
+ h1, h2, h3 { font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; }
204
+ h1 { font-size: 1.6rem; margin: 0; }
205
+ h2 { font-size: 1.05rem; margin: 0 0 0.9rem; color: var(--ink); }
206
+ .eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.7rem; color: var(--ink-dim); font-weight: 600; }
207
+ .mono { font-family: var(--mono); }
208
+ .container { max-width: 1180px; margin: 0 auto; }
209
+ header.top {
210
+ display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 1rem;
211
+ border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; margin-bottom: 1.75rem;
212
+ }
213
+ .updated { color: var(--ink-dim); font-size: 0.82rem; }
214
+ .phase-badge {
215
+ display: inline-block; padding: 0.2rem 0.65rem; border-radius: 999px; font-size: 0.75rem;
216
+ font-weight: 600; background: var(--accent-soft); color: var(--accent); font-family: var(--mono);
217
+ }
218
+
219
+ .stat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem; margin-bottom: 2rem; }
220
+ .stat-card {
221
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem;
222
+ }
223
+ .stat-card .value { font-size: 1.5rem; font-weight: 700; font-family: var(--mono); }
224
+ .stat-card .label { font-size: 0.72rem; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.15rem; }
225
+
226
+ section { margin-bottom: 2.25rem; }
227
+
228
+ .milestones { display: flex; gap: 0.6rem; overflow-x: auto; padding-bottom: 0.3rem; }
229
+ .milestone {
230
+ flex: 0 0 auto; min-width: 190px; border: 1px solid var(--border); border-radius: 10px;
231
+ padding: 0.85rem 1rem; background: var(--surface);
232
+ }
233
+ .milestone.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
234
+ .milestone.done { opacity: 0.75; }
235
+ .milestone .name { font-weight: 650; font-size: 0.9rem; }
236
+ .milestone .frac { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-dim); margin-top: 0.2rem; }
237
+ .bar { height: 6px; border-radius: 999px; background: var(--pending-soft); overflow: hidden; margin-top: 0.5rem; }
238
+ .bar > i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
239
+
240
+ .wave-row { margin-bottom: 1.25rem; }
241
+ .wave-label { font-family: var(--mono); font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.5rem; }
242
+ .wave-modules { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
243
+
244
+ .module-card { border: 1px solid var(--border); border-radius: 10px; background: var(--surface); overflow: hidden; }
245
+ .module-card > summary {
246
+ list-style: none; cursor: pointer; padding: 0.85rem 1rem; display: flex; flex-direction: column; gap: 0.4rem;
247
+ }
248
+ .module-card > summary::-webkit-details-marker { display: none; }
249
+ .module-card-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
250
+ .module-name { font-weight: 650; font-size: 0.92rem; }
251
+ .pill { font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.55rem; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }
252
+ .pill.complete { background: var(--complete-soft); color: var(--complete); }
253
+ .pill.in_progress { background: var(--progress-soft); color: var(--progress); }
254
+ .pill.pending { background: var(--pending-soft); color: var(--pending); }
255
+ .module-card .bar > i.complete { background: var(--complete); }
256
+ .module-card .bar > i.in_progress { background: var(--progress); }
257
+ .module-card .bar > i.pending { background: var(--pending); }
258
+ .module-frac { font-family: var(--mono); font-size: 0.75rem; color: var(--ink-dim); }
259
+ .deps { font-size: 0.72rem; color: var(--ink-dim); }
260
+ .deps .dep { font-family: var(--mono); background: var(--pending-soft); padding: 0.05rem 0.35rem; border-radius: 5px; margin-right: 0.2rem; }
261
+ .criteria-list { list-style: none; margin: 0; padding: 0 1rem 0.9rem; border-top: 1px solid var(--border); }
262
+ .criteria-list li { padding: 0.4rem 0; font-size: 0.82rem; border-bottom: 1px dashed var(--border); }
263
+ .criteria-list li:last-child { border-bottom: none; }
264
+ .crit-row { display: flex; gap: 0.5rem; }
265
+ .crit-id { font-family: var(--mono); color: var(--ink-dim); flex: 0 0 auto; }
266
+ .crit-status { flex: 0 0 auto; margin-left: auto; }
267
+ .qa-chips { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.35rem; }
268
+ .qa-chip {
269
+ font-family: var(--mono); font-size: 0.65rem; font-weight: 600; padding: 0.1rem 0.4rem;
270
+ border-radius: 5px; text-decoration: none; white-space: nowrap;
271
+ }
272
+ .qa-chip.complete { background: var(--complete-soft); color: var(--complete); }
273
+ .qa-chip.blocked { background: var(--blocked-soft); color: var(--blocked); }
274
+ .qa-chip.pending { background: var(--pending-soft); color: var(--pending); }
275
+ .qa-chip.progress { background: var(--progress-soft); color: var(--progress); }
276
+ a.qa-chip:hover { text-decoration: underline; }
277
+
278
+ .wave-gates { display: flex; gap: 0.4rem; flex-wrap: wrap; }
279
+
280
+ .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.85rem; }
281
+ .info-card { border: 1px solid var(--border); border-radius: 10px; background: var(--surface); padding: 0.9rem 1rem; font-size: 0.85rem; }
282
+ .info-card h3 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-dim); margin: 0 0 0.4rem; }
283
+
284
+ .chain-ok { color: var(--complete); font-weight: 600; }
285
+ .chain-break { color: var(--blocked); font-weight: 700; }
286
+
287
+ footer { color: var(--ink-dim); font-size: 0.78rem; border-top: 1px solid var(--border); padding-top: 1rem; margin-top: 2.5rem; }
288
+ footer a { color: var(--accent); }
289
+
290
+ /* Tabs -- pure CSS (radio + sibling selectors), no JS, same offline/no-framework
291
+ commitment as the rest of this file. */
292
+ .tab-input { position: absolute; opacity: 0; pointer-events: none; }
293
+ .tab-bar { display: flex; gap: 0.35rem; border-bottom: 1px solid var(--border); margin-bottom: 1.75rem; flex-wrap: wrap; }
294
+ .tab-label {
295
+ cursor: pointer; padding: 0.55rem 1rem; font-size: 0.85rem; font-weight: 600; color: var(--ink-dim);
296
+ border-bottom: 2px solid transparent; margin-bottom: -1px; user-select: none;
297
+ }
298
+ .tab-label:hover { color: var(--ink); }
299
+ .tab-panel { display: none; }
300
+ #tab-overview:checked ~ .tab-bar label[for="tab-overview"],
301
+ #tab-objective:checked ~ .tab-bar label[for="tab-objective"],
302
+ #tab-audit:checked ~ .tab-bar label[for="tab-audit"],
303
+ #tab-arch:checked ~ .tab-bar label[for="tab-arch"],
304
+ #tab-design:checked ~ .tab-bar label[for="tab-design"] {
305
+ color: var(--accent); border-bottom-color: var(--accent);
306
+ }
307
+ #tab-overview:checked ~ #panel-overview,
308
+ #tab-objective:checked ~ #panel-objective,
309
+ #tab-audit:checked ~ #panel-audit,
310
+ #tab-arch:checked ~ #panel-arch,
311
+ #tab-design:checked ~ #panel-design {
312
+ display: block;
313
+ }
314
+
315
+ .pcp-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
316
+ .pcp-table th, .pcp-table td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
317
+ .pcp-table th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-dim); font-weight: 600; }
318
+ .pcp-table td.mono { font-family: var(--mono); }
319
+ .gap-list { list-style: none; margin: 0; padding: 0; }
320
+ .gap-list li { padding: 0.4rem 0; border-bottom: 1px dashed var(--border); font-size: 0.85rem; }
321
+ .gap-list li:last-child { border-bottom: none; }
322
+ .prose { font-size: 0.9rem; line-height: 1.6; white-space: pre-wrap; }
323
+ .flag-warn { color: var(--progress); font-weight: 700; }
324
+ """
325
+
326
+
327
+ def _bar(pct: float, cls: str = "") -> str:
328
+ return f'<div class="bar"><i class="{cls}" style="width:{pct * 100:.0f}%"></i></div>'
329
+
330
+
331
+ def _milestone_html(p: dict) -> str:
332
+ pct = p["done"] / p["total"] if p["total"] else 0.0
333
+ cls = "current" if p["is_current"] else ("done" if pct >= 1 else "")
334
+ return f"""
335
+ <div class="milestone {cls}">
336
+ <div class="name">{escape(p["name"])}</div>
337
+ <div class="frac">{p["done"]}/{p["total"]} exit criteria</div>
338
+ {_bar(pct)}
339
+ </div>"""
340
+
341
+
342
+ RESULT_CLASS = {"pass": "complete", "block": "blocked", "error": "blocked", "skipped": "pending", "bypassed": "progress"}
343
+
344
+
345
+ def _qa_chip_html(check: str, record: dict) -> str:
346
+ cls = RESULT_CLASS.get(record.get("result"), "pending")
347
+ label = escape(CHECK_LABEL.get(check, check))
348
+ evidence_path = record.get("evidence_path")
349
+ if evidence_path:
350
+ return f'<a class="qa-chip {cls}" href=".pcp/{escape(evidence_path)}" title="{escape(record.get("result", ""))}">{label}</a>'
351
+ return f'<span class="qa-chip {cls}" title="{escape(record.get("result", ""))}">{label}</span>'
352
+
353
+
354
+ def _criterion_html(c: dict) -> str:
355
+ status = c.get("status", "pending")
356
+ mark = {"complete": "✓", "pending": "—"}.get(status, "—")
357
+ qa = c.get("qa") or {}
358
+ chips = "".join(_qa_chip_html(check, rec) for check, rec in sorted(qa.items()))
359
+ chips_html = f'<div class="qa-chips">{chips}</div>' if chips else ""
360
+ return f"""
361
+ <li>
362
+ <div class="crit-row"><span class="crit-id">{escape(c["id"])}</span>
363
+ <span>{escape(c["description"])}</span>
364
+ <span class="crit-status">{mark}</span></div>
365
+ {chips_html}
366
+ </li>"""
367
+
368
+
369
+ def _module_card_html(m: dict) -> str:
370
+ pct = m["complete"] / m["total"] if m["total"] else 0.0
371
+ status = m["status"]
372
+ open_attr = " open" if status == "in_progress" else ""
373
+ deps_html = ""
374
+ if m["dependencies"]:
375
+ chips = "".join(f'<span class="dep">{escape(d)}</span>' for d in m["dependencies"])
376
+ deps_html = f'<div class="deps">depends on {chips}</div>'
377
+ criteria_html = "".join(_criterion_html(c) for c in m["criteria"])
378
+ return f"""
379
+ <details class="module-card"{open_attr}>
380
+ <summary>
381
+ <div class="module-card-top">
382
+ <span class="module-name mono">{escape(m["name"])}</span>
383
+ <span class="pill {status}">{STATUS_LABEL[status]}</span>
384
+ </div>
385
+ <div class="module-frac">{m["complete"]}/{m["total"]} criteria</div>
386
+ {_bar(pct, status)}
387
+ {deps_html}
388
+ </summary>
389
+ <ul class="criteria-list">{criteria_html}</ul>
390
+ </details>"""
391
+
392
+
393
+ def _render_objective_gaps_html(data: dict) -> str:
394
+ obj = data.get("objective_text") or "_No objective.md found. Run `pcp init`._"
395
+ gaps = data.get("pending_gaps") or []
396
+ gaps_html = (
397
+ "".join(f"<li>{escape(g)}</li>" for g in gaps)
398
+ if gaps else '<li style="color:var(--ink-dim)">All acceptance criteria met.</li>'
399
+ )
400
+ return f"""
401
+ <section>
402
+ <h2>Objective</h2>
403
+ <div class="info-card"><div class="prose">{escape(obj)}</div></div>
404
+ </section>
405
+ <section>
406
+ <h2>Pending Gaps</h2>
407
+ <ul class="gap-list">{gaps_html}</ul>
408
+ </section>"""
409
+
410
+
411
+ def _render_audit_trail_html(prov: dict) -> str:
412
+ controls = prov.get("controls", {})
413
+ per_control = prov.get("per_control", {})
414
+ standing_gaps = set(prov.get("standing_gap_cids", []))
415
+ never_exercised = set(prov.get("never_exercised_cids", []))
416
+ bypasses = prov.get("bypasses", [])
417
+
418
+ rows = []
419
+ for cid, c in sorted(controls.items()):
420
+ totals = per_control.get(cid, {})
421
+ if cid == "CTRL-010":
422
+ status = f"{len(bypasses)} bypass(es) logged" if bypasses else "0 bypasses"
423
+ elif cid in never_exercised:
424
+ status = '<span class="flag-warn">GAP — never invoked</span>'
425
+ elif cid in standing_gaps:
426
+ status = '<span class="flag-warn">GAP — tool never detected</span>'
427
+ else:
428
+ status = f"{totals.get('pass', 0)}/{totals.get('total', 0)} pass"
429
+ practices = ", ".join(c.get("ssdf_practice", []))
430
+ rows.append(
431
+ f'<tr><td class="mono">{escape(cid)}</td><td>{escape(c.get("name",""))}</td>'
432
+ f'<td>{escape(practices)}</td><td>{status}</td></tr>'
433
+ )
434
+ table_html = (
435
+ f'<table class="pcp-table"><thead><tr><th>Control</th><th>Name</th>'
436
+ f'<th>SSDF Practice</th><th>Status</th></tr></thead><tbody>{"".join(rows)}</tbody></table>'
437
+ if rows else '<p style="color:var(--ink-dim)">No controls.yaml found.</p>'
438
+ )
439
+
440
+ bypass_html = ""
441
+ if bypasses:
442
+ items = "".join(
443
+ f'<li>{escape(b.get("timestamp",""))} — {escape(b.get("reason",""))} '
444
+ f'<span style="color:var(--ink-dim)">(rules: {", ".join(b.get("rules_bypassed", []))})</span></li>'
445
+ for b in bypasses[-10:]
446
+ )
447
+ bypass_html = f'<section><h2>Bypass Ledger</h2><ul class="gap-list">{items}</ul></section>'
448
+
449
+ return f"""
450
+ <section>
451
+ <h2>SSDF Crosswalk</h2>
452
+ <div class="info-card">{table_html}</div>
453
+ </section>
454
+ {bypass_html}"""
455
+
456
+
457
+ TIER_LABEL_SHORT = {1: "Deterministic", 2: "Solver", 3: "ML", 4: "RAG", 5: "Cache", 6: "Deep-think"}
458
+
459
+
460
+ def _render_architecture_justification_html(aj: dict) -> str:
461
+ tier_counts = aj.get("tier_counts", {})
462
+ flagged = aj.get("flagged_count", 0)
463
+ modules = aj.get("modules", [])
464
+
465
+ tier_rows = "".join(
466
+ f'<tr><td class="mono">{t}</td><td>{escape(TIER_LABEL_SHORT.get(t, "?"))}</td>'
467
+ f'<td class="mono">{tier_counts.get(t, 0)}</td></tr>'
468
+ for t in sorted(tier_counts)
469
+ )
470
+ tier_table = (
471
+ f'<table class="pcp-table"><thead><tr><th>Tier</th><th>Label</th><th>Criteria</th></tr></thead>'
472
+ f'<tbody>{tier_rows}</tbody></table>'
473
+ )
474
+
475
+ flag_note = (
476
+ f'<p class="flag-warn">{flagged} decision(s) are coerced placeholders — not a real deliberation, review before trusting.</p>'
477
+ if flagged else ""
478
+ )
479
+
480
+ if not modules:
481
+ modules_html = '<p style="color:var(--ink-dim)">No modules found, or none use the v2.0 logic_tier/build_vs_buy schema yet.</p>'
482
+ else:
483
+ cards = []
484
+ for m in modules:
485
+ mbvb = m.get("module_build_vs_buy")
486
+ mbvb_html = (
487
+ f'<div style="margin-bottom:0.6rem"><strong>Module-level build-vs-buy:</strong> '
488
+ f'<span class="mono">{escape(mbvb.get("decision",""))}</span> — {escape(mbvb.get("rationale",""))}</div>'
489
+ if mbvb else ""
490
+ )
491
+ def _tier_cell(c):
492
+ tier = c.get("logic_tier")
493
+ if tier is None:
494
+ return "—"
495
+ flag = " ⚠" if c["flagged"] else ""
496
+ return f'{tier} ({escape(TIER_LABEL_SHORT.get(tier, "?"))}){flag}'
497
+
498
+ crit_rows = "".join(
499
+ f'<tr><td class="mono">{escape(c["id"])}</td><td>{escape(c["description"])}</td>'
500
+ f'<td class="mono">{_tier_cell(c)}</td>'
501
+ f'<td class="mono">{escape((c["build_vs_buy"] or {}).get("decision") or "—")}</td></tr>'
502
+ for c in m["criteria"]
503
+ )
504
+ crit_table = (
505
+ f'<table class="pcp-table"><thead><tr><th>Criterion</th><th>Description</th>'
506
+ f'<th>Tier</th><th>Build-vs-Buy</th></tr></thead><tbody>{crit_rows}</tbody></table>'
507
+ if m["criteria"] else ""
508
+ )
509
+ cards.append(
510
+ f'<div class="info-card" style="margin-bottom:0.85rem"><h3>{escape(m["module"])}</h3>{mbvb_html}{crit_table}</div>'
511
+ )
512
+ modules_html = "".join(cards)
513
+
514
+ return f"""
515
+ <section>
516
+ <h2>Logic-Tier Distribution</h2>
517
+ <div class="info-card">{tier_table}</div>
518
+ {flag_note}
519
+ </section>
520
+ <section>
521
+ <h2>Per-Module Decisions</h2>
522
+ <div>{modules_html}</div>
523
+ </section>"""
524
+
525
+
526
+ DESIGN_RUNG_LABEL = {1: "Built, Hidden", 2: "Exposed, Undiscoverable", 3: "Exposed, Discoverable", 4: "Exposed, Enriched"}
527
+
528
+
529
+ def _render_design_audit_html(da: dict) -> str:
530
+ rung_counts = da.get("rung_counts", {})
531
+ modules = da.get("modules", [])
532
+
533
+ rung_rows = "".join(
534
+ f'<tr><td class="mono">{r}</td><td>{escape(DESIGN_RUNG_LABEL.get(r, "?"))}</td>'
535
+ f'<td class="mono">{rung_counts.get(r, 0)}</td></tr>'
536
+ for r in sorted(rung_counts)
537
+ )
538
+ rung_table = (
539
+ f'<table class="pcp-table"><thead><tr><th>Rung</th><th>Label</th><th>Criteria</th></tr></thead>'
540
+ f'<tbody>{rung_rows}</tbody></table>'
541
+ )
542
+
543
+ if not modules:
544
+ modules_html = '<p style="color:var(--ink-dim)">No UI-facing criteria found yet.</p>'
545
+ else:
546
+ cards = []
547
+ for m in modules:
548
+ crit_rows = "".join(
549
+ f'<tr><td class="mono">{escape(c["id"])}</td><td>{escape(c["description"])}'
550
+ f'{" ⚠" if c["rung"] == 1 else ""}</td>'
551
+ f'<td class="mono">{c["rung"]} ({escape(DESIGN_RUNG_LABEL.get(c["rung"], "?"))})</td>'
552
+ f'<td>{escape(c["jtbd_framing"]) or "—"}</td></tr>'
553
+ for c in m["criteria"]
554
+ )
555
+ cards.append(
556
+ f'<div class="info-card" style="margin-bottom:0.85rem"><h3>{escape(m["module"])}</h3>'
557
+ f'<table class="pcp-table"><thead><tr><th>Criterion</th><th>Description</th><th>Rung</th><th>JTBD Framing</th></tr></thead>'
558
+ f'<tbody>{crit_rows}</tbody></table></div>'
559
+ )
560
+ modules_html = "".join(cards)
561
+
562
+ return f"""
563
+ <section>
564
+ <h2>Feature Exposure Ladder</h2>
565
+ <div class="info-card">{rung_table}</div>
566
+ <p style="color:var(--ink-dim); font-size:0.8rem;">Maps to Google HEART's Adoption pillar, computed statically from declared intent, not live usage telemetry.</p>
567
+ </section>
568
+ <section>
569
+ <h2>Per-Module UI Criteria</h2>
570
+ <div>{modules_html}</div>
571
+ </section>"""
572
+
573
+
574
+ def render_html(data: dict) -> str:
575
+ stats = [
576
+ ("Criteria", f'{data["complete"]}/{data["total"]}'),
577
+ ("Coverage", f'{data["score_pct"]:.0%}'),
578
+ ("Test Coverage", data["test_coverage"] or "—"),
579
+ ("Bypasses", str(data["bypass_count"])),
580
+ ]
581
+ stat_cards = "".join(
582
+ f'<div class="stat-card"><div class="value">{escape(v)}</div><div class="label">{escape(l)}</div></div>'
583
+ for l, v in stats
584
+ )
585
+
586
+ milestones_html = "".join(_milestone_html(p) for p in data["phases"]) or \
587
+ '<div class="milestone">No SDLC phases defined.</div>'
588
+
589
+ waves_html = ""
590
+ for w in range(data["num_waves"]):
591
+ wave_modules = [m for m in data["modules"] if m["wave"] == w]
592
+ if not wave_modules:
593
+ continue
594
+ cards = "".join(_module_card_html(m) for m in wave_modules)
595
+ wave_label = f"Wave {w}" if data["num_waves"] > 1 else "Modules"
596
+ waves_html += f'<div class="wave-row"><div class="wave-label">{wave_label}</div><div class="wave-modules">{cards}</div></div>'
597
+ if not waves_html:
598
+ waves_html = '<p class="mono" style="color:var(--ink-dim)">No modules found.</p>'
599
+
600
+ ci = data["chain_integrity"]
601
+ ci_lines = []
602
+ for log_name, breaks in ci.items():
603
+ if breaks:
604
+ ci_lines.append(f'<div><span class="chain-break">✗ {escape(log_name)}: {len(breaks)} break(s)</span></div>')
605
+ else:
606
+ ci_lines.append(f'<div><span class="chain-ok">✓ {escape(log_name)}: intact</span></div>')
607
+
608
+ info_cards = []
609
+ if data["telemetry_summary"]:
610
+ info_cards.append(("Build Efficiency", data["telemetry_summary"]))
611
+ if data["token_summary"]:
612
+ info_cards.append(("Token Spend", data["token_summary"]))
613
+ if data["brd_summary"]:
614
+ info_cards.append(("Business Requirements", data["brd_summary"]))
615
+ if data["decision_log_summary"]:
616
+ info_cards.append(("Technical Decisions", data["decision_log_summary"]))
617
+ info_cards_html = "".join(
618
+ f'<div class="info-card"><h3>{escape(t)}</h3><div>{escape(v)}</div></div>' for t, v in info_cards
619
+ )
620
+
621
+ wave_gates_html = "".join(_qa_chip_html(r.get("check", ""), r) for r in data["wave_gates"])
622
+ wave_gates_section = (
623
+ f'<section><h2>Wave Gates</h2><div class="info-card"><div class="wave-gates">{wave_gates_html}</div></div></section>'
624
+ if data["wave_gates"] else ""
625
+ )
626
+
627
+ bypass_html = ""
628
+ if data["recent_bypasses"]:
629
+ items = "".join(
630
+ f'<li>{escape(b.get("timestamp", ""))} — {escape(b.get("reason", ""))}</li>'
631
+ for b in data["recent_bypasses"]
632
+ )
633
+ bypass_html = f'<div class="info-card"><h3>Recent Bypasses</h3><ul class="mono" style="margin:0;padding-left:1.1rem;font-size:0.8rem">{items}</ul></div>'
634
+
635
+ objective_gaps_html = _render_objective_gaps_html(data)
636
+ audit_trail_html = _render_audit_trail_html(data["provenance"])
637
+ arch_justification_html = _render_architecture_justification_html(data["architecture_justification"])
638
+ design_audit_html = _render_design_audit_html(data["design_audit"])
639
+
640
+ return f"""<!doctype html>
641
+ <html lang="en">
642
+ <head>
643
+ <meta charset="utf-8">
644
+ <meta name="viewport" content="width=device-width, initial-scale=1">
645
+ <title>PCP Dashboard — {escape(data["project_name"])}</title>
646
+ <style>{CSS}</style>
647
+ </head>
648
+ <body>
649
+ <div class="container">
650
+ <header class="top">
651
+ <div>
652
+ <div class="eyebrow">PCP Dashboard</div>
653
+ <h1>{escape(data["project_name"])}</h1>
654
+ </div>
655
+ <div style="text-align:right">
656
+ <span class="phase-badge">{escape(data["phase_name"])}</span>
657
+ <div class="updated">Updated {escape(data["timestamp"])}</div>
658
+ </div>
659
+ </header>
660
+
661
+ <div class="stat-strip">{stat_cards}</div>
662
+
663
+ <input class="tab-input" type="radio" name="pcp-tabs" id="tab-overview" checked>
664
+ <input class="tab-input" type="radio" name="pcp-tabs" id="tab-objective">
665
+ <input class="tab-input" type="radio" name="pcp-tabs" id="tab-audit">
666
+ <input class="tab-input" type="radio" name="pcp-tabs" id="tab-arch">
667
+ <input class="tab-input" type="radio" name="pcp-tabs" id="tab-design">
668
+
669
+ <nav class="tab-bar">
670
+ <label class="tab-label" for="tab-overview">Overview</label>
671
+ <label class="tab-label" for="tab-objective">Objective &amp; Gaps</label>
672
+ <label class="tab-label" for="tab-audit">Audit Trail</label>
673
+ <label class="tab-label" for="tab-arch">Architecture Justification</label>
674
+ <label class="tab-label" for="tab-design">Design</label>
675
+ </nav>
676
+
677
+ <div id="panel-overview" class="tab-panel">
678
+ <section>
679
+ <h2>Milestones</h2>
680
+ <div class="milestones">{milestones_html}</div>
681
+ </section>
682
+
683
+ <section>
684
+ <h2>Module Status</h2>
685
+ {waves_html}
686
+ </section>
687
+
688
+ <section>
689
+ <h2>Evidence Integrity</h2>
690
+ <div class="info-card">{"".join(ci_lines)}</div>
691
+ </section>
692
+
693
+ {wave_gates_section}
694
+
695
+ {"<section><h2>Activity</h2><div class='info-grid'>" + info_cards_html + bypass_html + "</div></section>" if (info_cards or bypass_html) else ""}
696
+ </div>
697
+
698
+ <div id="panel-objective" class="tab-panel">{objective_gaps_html}</div>
699
+ <div id="panel-audit" class="tab-panel">{audit_trail_html}</div>
700
+ <div id="panel-arch" class="tab-panel">{arch_justification_html}</div>
701
+ <div id="panel-design" class="tab-panel">{design_audit_html}</div>
702
+
703
+ <footer>
704
+ Generated by <code class="mono">pcp dashboard</code>. Static snapshot — re-run to refresh. One file, no server.
705
+ </footer>
706
+ </div>
707
+ </body>
708
+ </html>
709
+ """
710
+
711
+
712
+ def write_dashboard(pcp_dir: Path) -> Path:
713
+ data = build_dashboard_data(pcp_dir)
714
+ html = render_html(data)
715
+ out = pcp_dir.parent / "dashboard.html"
716
+ out.write_text(html)
717
+ return out
718
+
719
+
720
+ @click.command()
721
+ @click.option("--path", "project_path", type=click.Path(), default=None,
722
+ help="Project root (default: cwd, walks up to find .pcp/).")
723
+ def dashboard(project_path: str | None):
724
+ """Generate a static HTML status dashboard — module status by wave, milestones, evidence integrity."""
725
+ try:
726
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
727
+ except NoPCPDir as e:
728
+ console.print(f"[red]Error:[/red] {e}")
729
+ sys.exit(2)
730
+
731
+ out_path = write_dashboard(pcp_dir)
732
+ console.print(f"[green]Dashboard written[/green] → {out_path.relative_to(pcp_dir.parent)}")