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
|
@@ -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 & 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)}")
|