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/deploy.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""pcp deploy — production deploy automation.
|
|
2
|
+
|
|
3
|
+
Per docs/greenfield.md Phase 6: checklist, trigger, smoke test, auto-rollback.
|
|
4
|
+
Deploy is the one place in the PCP lifecycle where an irreversible production
|
|
5
|
+
action happens — human approval is mandatory by default (`--yes` opts out for
|
|
6
|
+
CI use, deliberately not the default). Extra scrutiny if migration/PII/payment
|
|
7
|
+
-flavoured criteria are detected in this release.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import yaml
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
|
|
22
|
+
from pcp.schema.validator import load_yaml
|
|
23
|
+
from pcp.commands.doctor import load_integrations, check_environment
|
|
24
|
+
from pcp.commands.watch import check_deploy_health, notify
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
RISK_KEYWORDS = ["migration", "migrate", "pii", "personal data", "payment", "credit card", "compliance"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_deploy_check(project_root: Path) -> bool:
|
|
32
|
+
"""Shells out to `pcp deploy-check` (Layer 3) rather than importing it —
|
|
33
|
+
deploy_check.py's logic is a single click command body, not factored into
|
|
34
|
+
a reusable function, and shelling out exercises the exact same path a
|
|
35
|
+
human running it manually would."""
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[sys.executable, "-m", "pcp.cli", "deploy-check", "--path", str(project_root)],
|
|
38
|
+
cwd=project_root,
|
|
39
|
+
)
|
|
40
|
+
return result.returncode == 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collect_risk_flags(modules_dir: Path) -> list[str]:
|
|
44
|
+
flags = []
|
|
45
|
+
if not modules_dir.exists():
|
|
46
|
+
return flags
|
|
47
|
+
for acc_path in sorted(modules_dir.glob("*/acceptance.yaml")):
|
|
48
|
+
data = load_yaml(acc_path)
|
|
49
|
+
for c in data.get("criteria", []):
|
|
50
|
+
desc = (c.get("description") or "").lower()
|
|
51
|
+
for kw in RISK_KEYWORDS:
|
|
52
|
+
if kw in desc:
|
|
53
|
+
flags.append(f"{acc_path.parent.name}/{c['id']}: \"{c['description']}\" (matched '{kw}')")
|
|
54
|
+
break
|
|
55
|
+
return flags
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def log_deploy(pcp_dir: Path, entry: dict) -> None:
|
|
59
|
+
log_path = pcp_dir / "deploy_log.yaml"
|
|
60
|
+
existing = []
|
|
61
|
+
if log_path.exists():
|
|
62
|
+
data = yaml.safe_load(log_path.read_text()) or {}
|
|
63
|
+
existing = data.get("deploys", [])
|
|
64
|
+
existing.append(entry)
|
|
65
|
+
log_path.write_text(yaml.dump({"deploys": existing}, default_flow_style=False))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.command()
|
|
69
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
70
|
+
@click.option("--yes", is_flag=True, help="Skip the interactive approval prompt (CI/non-interactive use — opt-in, not default).")
|
|
71
|
+
@click.option("--rollout", default="100", help="Rollout percentage, logged for audit (e.g. 5, 25, 100).")
|
|
72
|
+
def deploy(project_path: str | None, yes: bool, rollout: str):
|
|
73
|
+
"""Production deploy — checklist, trigger, smoke test, auto-rollback."""
|
|
74
|
+
try:
|
|
75
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
76
|
+
except NoPCPDir as e:
|
|
77
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
78
|
+
sys.exit(2)
|
|
79
|
+
|
|
80
|
+
project_root = pcp_dir.parent
|
|
81
|
+
check_environment(pcp_dir, fatal_on_missing_required=False)
|
|
82
|
+
integrations = load_integrations(pcp_dir)
|
|
83
|
+
deploy_cfg = integrations.get("deploy") or {}
|
|
84
|
+
deploy_command = deploy_cfg.get("command")
|
|
85
|
+
health_url = deploy_cfg.get("health_check_url")
|
|
86
|
+
rollback_command = deploy_cfg.get("rollback_command")
|
|
87
|
+
|
|
88
|
+
console.print("[bold]Deploy checklist[/bold]")
|
|
89
|
+
console.print("[dim]Checking Layer 3 SDLC phase exit criteria (pcp deploy-check)...[/dim]")
|
|
90
|
+
if not run_deploy_check(project_root):
|
|
91
|
+
console.print("[red bold]BLOCKED — deploy-check failed. Resolve exit criteria before deploying.[/red bold]")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
console.print("[green]✓[/green] deploy-check passed")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
from pcp.commands.provenance import write_provenance
|
|
97
|
+
write_provenance(pcp_dir)
|
|
98
|
+
console.print("[dim]Audit evidence refreshed -> .pcp/provenance.md (review before approving)[/dim]")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
console.print(f"[dim]Provenance refresh skipped: {e}[/dim]")
|
|
101
|
+
|
|
102
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
103
|
+
risk_flags = collect_risk_flags(modules_dir)
|
|
104
|
+
|
|
105
|
+
# Deploy-time OPA policy (deploy_policy.rego — freeze windows, rollback
|
|
106
|
+
# requirements). Advisory infrastructure but its deny rules DO block:
|
|
107
|
+
# a policy a human wrote saying "don't deploy Fridays" means exactly that.
|
|
108
|
+
# Missing opa / missing policy file degrades silently, same as the other
|
|
109
|
+
# rego consumers.
|
|
110
|
+
from pcp import policy
|
|
111
|
+
decision = policy.evaluate(pcp_dir, "data.pcp.deploy.deny", {
|
|
112
|
+
"utc_weekday": datetime.now(timezone.utc).weekday(),
|
|
113
|
+
"risk_flag_count": len(risk_flags),
|
|
114
|
+
"rollback_configured": bool(rollback_command),
|
|
115
|
+
})
|
|
116
|
+
if decision.get("available") and not decision.get("undefined"):
|
|
117
|
+
denies = decision.get("value") or []
|
|
118
|
+
if denies:
|
|
119
|
+
for d in denies:
|
|
120
|
+
console.print(f"[red bold]Deploy policy DENY:[/red bold] {d}")
|
|
121
|
+
console.print("[dim]Edit .pcp/policies/deploy_policy.rego to change deploy-time policy.[/dim]")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
if risk_flags:
|
|
125
|
+
console.print("\n[yellow bold]Risk flags detected — review before approving:[/yellow bold]")
|
|
126
|
+
for f in risk_flags:
|
|
127
|
+
console.print(f" ⚠ {f}")
|
|
128
|
+
else:
|
|
129
|
+
console.print("[dim]No migration/PII/payment-flavoured criteria detected in this release.[/dim]")
|
|
130
|
+
|
|
131
|
+
console.print(f"\nRollout: {rollout}%")
|
|
132
|
+
console.print(f"Deploy command: {deploy_command or '(none configured — run `pcp doctor`)'}")
|
|
133
|
+
console.print(f"Health check: {health_url or '(none configured)'}")
|
|
134
|
+
console.print(f"Rollback command: {rollback_command or '(none configured)'}")
|
|
135
|
+
|
|
136
|
+
if not deploy_command:
|
|
137
|
+
console.print("[red]No deploy command configured. Run `pcp doctor` to set one.[/red]")
|
|
138
|
+
sys.exit(2)
|
|
139
|
+
|
|
140
|
+
if not yes:
|
|
141
|
+
prompt = "Approve this deploy?"
|
|
142
|
+
if risk_flags:
|
|
143
|
+
prompt = f"⚠ {len(risk_flags)} risk flag(s) above — approve this deploy anyway?"
|
|
144
|
+
if not click.confirm(prompt, default=False):
|
|
145
|
+
console.print("[yellow]Deploy aborted by user.[/yellow]")
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
|
|
148
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
149
|
+
console.print(f"\n[bold]Triggering deploy:[/bold] {deploy_command}")
|
|
150
|
+
trigger_result = subprocess.run(deploy_command, shell=True, cwd=project_root)
|
|
151
|
+
deploy_ok = trigger_result.returncode == 0
|
|
152
|
+
|
|
153
|
+
smoke_result = None
|
|
154
|
+
rolled_back = False
|
|
155
|
+
if deploy_ok and health_url:
|
|
156
|
+
console.print("[dim]Waiting 10s before smoke test...[/dim]")
|
|
157
|
+
time.sleep(10)
|
|
158
|
+
# Multi-sample smoke (build plan 3.6 — the Argo/Flagger/LaunchDarkly
|
|
159
|
+
# baseline gates on error RATE over a window, not one boolean probe;
|
|
160
|
+
# a single check catches only total outage, not a flapping deploy).
|
|
161
|
+
# N samples over an interval; more than max_failures failed samples
|
|
162
|
+
# fails the smoke test. Defaults keep the old behavior cheap-ish:
|
|
163
|
+
# 3 samples, 5s apart, zero failures tolerated.
|
|
164
|
+
samples = int(os.environ.get("PCP_DEPLOY_SMOKE_SAMPLES", "3"))
|
|
165
|
+
interval_s = float(os.environ.get("PCP_DEPLOY_SMOKE_INTERVAL_SEC", "5"))
|
|
166
|
+
max_failures = int(os.environ.get("PCP_DEPLOY_SMOKE_MAX_FAILURES", "0"))
|
|
167
|
+
failures = 0
|
|
168
|
+
for i in range(max(samples, 1)):
|
|
169
|
+
if i:
|
|
170
|
+
time.sleep(interval_s)
|
|
171
|
+
if check_deploy_health(health_url) is not True:
|
|
172
|
+
failures += 1
|
|
173
|
+
smoke_result = failures <= max_failures
|
|
174
|
+
console.print(f"[dim]Smoke: {samples - failures}/{samples} samples healthy (tolerance {max_failures}).[/dim]")
|
|
175
|
+
if smoke_result:
|
|
176
|
+
console.print("[green]✓ Smoke test passed.[/green]")
|
|
177
|
+
else:
|
|
178
|
+
console.print("[red]✗ Smoke test FAILED.[/red]")
|
|
179
|
+
if rollback_command:
|
|
180
|
+
console.print(f"[bold]Auto-rollback:[/bold] {rollback_command}")
|
|
181
|
+
rb = subprocess.run(rollback_command, shell=True, cwd=project_root)
|
|
182
|
+
rolled_back = rb.returncode == 0
|
|
183
|
+
notify(f"pcp deploy: smoke test failed, auto-rollback {'succeeded' if rolled_back else 'FAILED — needs human attention'}")
|
|
184
|
+
else:
|
|
185
|
+
notify("pcp deploy: smoke test failed, no rollback command configured — needs human attention")
|
|
186
|
+
|
|
187
|
+
log_deploy(pcp_dir, {
|
|
188
|
+
"timestamp": timestamp, "rollout_pct": rollout, "risk_flags": risk_flags,
|
|
189
|
+
"deploy_command": deploy_command, "trigger_succeeded": deploy_ok,
|
|
190
|
+
"smoke_test_passed": smoke_result, "rolled_back": rolled_back,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if not deploy_ok:
|
|
194
|
+
console.print("[red bold]Deploy trigger command failed.[/red bold]")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
console.print("\n[bold]Post-deploy:[/bold] watch P95 latency, error rate, memory for the next 15 minutes.")
|
|
198
|
+
if smoke_result is False and not rolled_back:
|
|
199
|
+
sys.exit(1)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""pcp deploy-check — Layer 3 deploy gate (deterministic SDLC phase exit)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
12
|
+
from pcp.schema.validator import validate_file, load_yaml
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
MAX_CURRENT_STATE_AGE_HOURS = 24
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_criterion(criterion: dict, project_root: Path) -> tuple[bool, str]:
|
|
20
|
+
check = criterion.get("check", "manual")
|
|
21
|
+
target = criterion.get("target", "")
|
|
22
|
+
|
|
23
|
+
if check == "file_exists":
|
|
24
|
+
path = project_root / target
|
|
25
|
+
return path.exists(), f"{'exists' if path.exists() else 'missing'}: {target}"
|
|
26
|
+
|
|
27
|
+
elif check == "ast_pattern":
|
|
28
|
+
pattern = criterion.get("pattern", "")
|
|
29
|
+
if not pattern or not target:
|
|
30
|
+
return criterion.get("status") == "complete", "manual (no pattern)"
|
|
31
|
+
path = project_root / target
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return False, f"file not found: {target}"
|
|
34
|
+
content = path.read_text(errors="replace")
|
|
35
|
+
matched = bool(re.search(pattern, content, re.MULTILINE))
|
|
36
|
+
return matched, f"pattern {'found' if matched else 'not found'} in {target}"
|
|
37
|
+
|
|
38
|
+
else: # manual
|
|
39
|
+
return criterion.get("status") == "complete", "manual"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_current_state_freshness(pcp_dir: Path) -> tuple[bool, str]:
|
|
43
|
+
cs = pcp_dir / "current_state.md"
|
|
44
|
+
if not cs.exists():
|
|
45
|
+
return False, "current_state.md not found — run `pcp scan`"
|
|
46
|
+
content = cs.read_text()
|
|
47
|
+
m = re.search(r"Generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)", content)
|
|
48
|
+
if not m:
|
|
49
|
+
return False, "current_state.md has no timestamp"
|
|
50
|
+
generated = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
51
|
+
now = datetime.now(timezone.utc)
|
|
52
|
+
age_hours = (now - generated).total_seconds() / 3600
|
|
53
|
+
ok = age_hours <= MAX_CURRENT_STATE_AGE_HOURS
|
|
54
|
+
return ok, f"current_state.md is {age_hours:.1f}h old (max {MAX_CURRENT_STATE_AGE_HOURS}h)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@click.command()
|
|
58
|
+
@click.option("--phase", default=None, help="Phase name to check (default: current_phase in SDLC_phase.yaml).")
|
|
59
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
60
|
+
@click.option("--skip-freshness", is_flag=True, help="Skip current_state.md age check.")
|
|
61
|
+
def deploy_check(phase: str | None, project_path: str | None, skip_freshness: bool):
|
|
62
|
+
"""Layer 3 deploy gate — enforce SDLC phase exit criteria."""
|
|
63
|
+
try:
|
|
64
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
65
|
+
except NoPCPDir as e:
|
|
66
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
67
|
+
sys.exit(2)
|
|
68
|
+
|
|
69
|
+
project_root = pcp_dir.parent
|
|
70
|
+
sdlc_path = pcp_dir / "SDLC_phase.yaml"
|
|
71
|
+
|
|
72
|
+
if not sdlc_path.exists():
|
|
73
|
+
console.print("[dim]No SDLC_phase.yaml found — skipping deploy-check.[/dim]")
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
|
|
76
|
+
schema_errors = validate_file(sdlc_path, "sdlc_phase")
|
|
77
|
+
if schema_errors:
|
|
78
|
+
console.print("[red]SDLC_phase.yaml schema errors:[/red]")
|
|
79
|
+
for e in schema_errors:
|
|
80
|
+
console.print(f" {e}")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
data = load_yaml(sdlc_path)
|
|
84
|
+
current_phase_name = phase or data.get("current_phase")
|
|
85
|
+
phases = {p["name"]: p for p in data.get("phases", [])}
|
|
86
|
+
|
|
87
|
+
if current_phase_name not in phases:
|
|
88
|
+
console.print(f"[red]Phase '{current_phase_name}' not found in SDLC_phase.yaml.[/red]")
|
|
89
|
+
sys.exit(2)
|
|
90
|
+
|
|
91
|
+
phase_data = phases[current_phase_name]
|
|
92
|
+
criteria = phase_data.get("exit_criteria", [])
|
|
93
|
+
|
|
94
|
+
console.print(f"[bold]Checking phase:[/bold] {current_phase_name}")
|
|
95
|
+
|
|
96
|
+
failures = []
|
|
97
|
+
passed = []
|
|
98
|
+
|
|
99
|
+
if not skip_freshness:
|
|
100
|
+
ok, detail = _check_current_state_freshness(pcp_dir)
|
|
101
|
+
if ok:
|
|
102
|
+
passed.append(f"current_state.md freshness: {detail}")
|
|
103
|
+
else:
|
|
104
|
+
failures.append(f"current_state.md stale: {detail}")
|
|
105
|
+
|
|
106
|
+
for c in criteria:
|
|
107
|
+
if c.get("status") == "complete":
|
|
108
|
+
# Already manually marked complete — trust it
|
|
109
|
+
passed.append(f"[{c['id']}] {c['description']}")
|
|
110
|
+
continue
|
|
111
|
+
ok, detail = _check_criterion(c, project_root)
|
|
112
|
+
if ok:
|
|
113
|
+
passed.append(f"[{c['id']}] {c['description']}")
|
|
114
|
+
else:
|
|
115
|
+
failures.append(f"[{c['id']}] {c['description']} → {detail}")
|
|
116
|
+
|
|
117
|
+
for p in passed:
|
|
118
|
+
console.print(f" [green]✓[/green] {p}")
|
|
119
|
+
|
|
120
|
+
from pcp import telemetry
|
|
121
|
+
telemetry.record(
|
|
122
|
+
pcp_dir, cycle="qa", cycle_number=None, check="deploy-phase-exit",
|
|
123
|
+
control_id="CTRL-009", module=None, submodule=None, criterion_id=None,
|
|
124
|
+
files=[], result="block" if failures else "pass", errors=failures, error_count=len(failures),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if failures:
|
|
128
|
+
console.print(f"\n[red bold]BLOCKED — {len(failures)} exit criteria not met:[/red bold]")
|
|
129
|
+
for f in failures:
|
|
130
|
+
console.print(f" [red]✗[/red] {f}")
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
console.print(f"\n[green bold]✓ Phase '{current_phase_name}' exit criteria met.[/green bold]")
|
|
134
|
+
sys.exit(0)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""pcp design-audit — PCP Design lifecycle, stage 5 (Audit/rollup).
|
|
2
|
+
|
|
3
|
+
Feature Exposure Ladder: for every UI-facing acceptance criterion in a
|
|
4
|
+
project, classifies how discoverable it actually is to an end user, based
|
|
5
|
+
on the design_justification stage 2 (Decide) already recorded (or not) on
|
|
6
|
+
that criterion. Pure aggregation over what's already on disk -- no LLM,
|
|
7
|
+
never hand-edited, same posture as architecture_justification.py, but a
|
|
8
|
+
different audience: this is for product/PM ("can a user find and use this"),
|
|
9
|
+
architecture_justification.md is for engineering ("was this the right
|
|
10
|
+
technical call").
|
|
11
|
+
|
|
12
|
+
Maps directly onto Google's HEART framework's Adoption pillar, computed
|
|
13
|
+
statically from declared intent rather than live usage telemetry (Happiness/
|
|
14
|
+
Engagement/Retention need real instrumentation -- a separate, deferred
|
|
15
|
+
build-vs-buy decision, not something inferable from a build-time audit).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
import yaml
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
from pcp import nav_graph
|
|
28
|
+
from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
|
|
29
|
+
from pcp.commands.build import _is_ui_facing_criterion
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
RUNG_LABEL = {
|
|
34
|
+
1: "Built, Hidden",
|
|
35
|
+
2: "Exposed, Undiscoverable",
|
|
36
|
+
3: "Exposed, Discoverable",
|
|
37
|
+
4: "Exposed, Enriched",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Crude, deterministic JTBD-shape check: a real "when X, do Y" conditional
|
|
41
|
+
# framing vs. a bare feature-name restatement. Good enough as a rung-3→4
|
|
42
|
+
# gate; false negatives just mean a genuinely good framing sits one rung
|
|
43
|
+
# lower than it deserves, which is a safe direction to err in.
|
|
44
|
+
_JTBD_MARKERS = ("when ", "if ", "whenever ")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_yaml(path: Path) -> dict:
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
return yaml.safe_load(path.read_text()) or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _classify_rung(criterion: dict, nav: dict | None = None,
|
|
54
|
+
depth_threshold: int = 3) -> int | None:
|
|
55
|
+
"""Rung from the MEASURED artifact where possible, `None` when unknown.
|
|
56
|
+
|
|
57
|
+
This used to read one field: no `design_justification` -> rung 1. Measured
|
|
58
|
+
2026-07-27 on Project O that gave 101 at rung 1, 24 at rung 4, and
|
|
59
|
+
**zero** at rungs 2 and 3 -- a binary condition wearing a four-rung costume,
|
|
60
|
+
because nothing ever writes a partial justification. "101 Built, Hidden"
|
|
61
|
+
described 101 missing fields, not 101 hidden features.
|
|
62
|
+
|
|
63
|
+
Rungs 1-3 now come from `nav_graph`: is the criterion's screen reachable
|
|
64
|
+
from the app's entry page, and how deep. Only rung 4 still consults the
|
|
65
|
+
declaration, because "is this framed as a real job-to-be-done" is genuinely
|
|
66
|
+
a property of the writing, not of the artifact.
|
|
67
|
+
|
|
68
|
+
`None` means not determinable -- no front end, or a criterion that cannot be
|
|
69
|
+
tied to a screen. That is reported separately and is NOT rung 1. Reporting
|
|
70
|
+
an absent measurement as a bad measurement is the whole defect being fixed.
|
|
71
|
+
"""
|
|
72
|
+
target = criterion.get("target") or ""
|
|
73
|
+
screen = nav_graph.screen_for_target(target, nav) if nav else None
|
|
74
|
+
if screen is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
depths = (nav or {}).get("depths", {})
|
|
78
|
+
if screen not in depths:
|
|
79
|
+
return 1 # measured: genuinely unreachable from entry
|
|
80
|
+
if depths[screen] > depth_threshold:
|
|
81
|
+
return 2 # reachable, but buried
|
|
82
|
+
|
|
83
|
+
dj = criterion.get("design_justification") or {}
|
|
84
|
+
checklist = dj.get("checklist_passed") or []
|
|
85
|
+
jtbd = (dj.get("jtbd_framing") or "").strip()
|
|
86
|
+
if jtbd and any(m in jtbd.lower() for m in _JTBD_MARKERS) and checklist:
|
|
87
|
+
return 4
|
|
88
|
+
return 3
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _nav_depth_threshold() -> int:
|
|
92
|
+
import os
|
|
93
|
+
return int(os.environ.get("PCP_NAV_DEPTH_THRESHOLD", "3"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_design_audit(pcp_dir: Path) -> dict:
|
|
97
|
+
"""Pure aggregation, no LLM -- safe to call at any point."""
|
|
98
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
99
|
+
modules = []
|
|
100
|
+
rung_counts = {r: 0 for r in RUNG_LABEL}
|
|
101
|
+
# Criteria whose screen cannot be identified are counted here, NOT dumped
|
|
102
|
+
# into rung 1. "We could not measure this" and "this is hidden" are
|
|
103
|
+
# different facts and must not share a bucket.
|
|
104
|
+
undetermined = 0
|
|
105
|
+
nav = nav_graph.analyse(pcp_dir.parent)
|
|
106
|
+
total_ui_criteria = 0
|
|
107
|
+
nav_depths: list[int] = []
|
|
108
|
+
nav_depth_missing = 0
|
|
109
|
+
customizable_count = 0
|
|
110
|
+
|
|
111
|
+
if modules_dir.exists():
|
|
112
|
+
for mod_path in sorted(p for p in modules_dir.iterdir() if p.is_dir()):
|
|
113
|
+
acceptance = _load_yaml(mod_path / "acceptance.yaml")
|
|
114
|
+
ui_criteria = []
|
|
115
|
+
for c in acceptance.get("criteria", []):
|
|
116
|
+
if not _is_ui_facing_criterion(c):
|
|
117
|
+
continue
|
|
118
|
+
rung = _classify_rung(c, nav, _nav_depth_threshold())
|
|
119
|
+
if rung is None:
|
|
120
|
+
undetermined += 1
|
|
121
|
+
else:
|
|
122
|
+
rung_counts[rung] += 1
|
|
123
|
+
total_ui_criteria += 1
|
|
124
|
+
dj = c.get("design_justification") or {}
|
|
125
|
+
nav_depth = c.get("nav_depth")
|
|
126
|
+
if nav_depth is None:
|
|
127
|
+
nav_depth_missing += 1
|
|
128
|
+
else:
|
|
129
|
+
nav_depths.append(nav_depth)
|
|
130
|
+
customizable = bool(dj.get("customizable"))
|
|
131
|
+
if customizable:
|
|
132
|
+
customizable_count += 1
|
|
133
|
+
ui_criteria.append({
|
|
134
|
+
"id": c.get("id"),
|
|
135
|
+
"description": c.get("description"),
|
|
136
|
+
"rung": rung,
|
|
137
|
+
"jtbd_framing": dj.get("jtbd_framing", ""),
|
|
138
|
+
"deviations_from_system": dj.get("deviations_from_system", ""),
|
|
139
|
+
"nav_depth": nav_depth,
|
|
140
|
+
"customizable": customizable,
|
|
141
|
+
"customization_notes": dj.get("customization_notes", ""),
|
|
142
|
+
})
|
|
143
|
+
if ui_criteria:
|
|
144
|
+
modules.append({"module": mod_path.name, "criteria": ui_criteria})
|
|
145
|
+
|
|
146
|
+
threshold = _nav_depth_threshold()
|
|
147
|
+
within_threshold = sum(1 for d in nav_depths if d <= threshold)
|
|
148
|
+
nav_depth_summary = {
|
|
149
|
+
"declared": len(nav_depths),
|
|
150
|
+
"missing": nav_depth_missing,
|
|
151
|
+
"max": max(nav_depths) if nav_depths else None,
|
|
152
|
+
"avg": round(sum(nav_depths) / len(nav_depths), 1) if nav_depths else None,
|
|
153
|
+
"within_threshold_pct": round(within_threshold / len(nav_depths), 2) if nav_depths else None,
|
|
154
|
+
"threshold": threshold,
|
|
155
|
+
}
|
|
156
|
+
customization_summary = {
|
|
157
|
+
"customizable_count": customizable_count,
|
|
158
|
+
"total_ui_criteria": total_ui_criteria,
|
|
159
|
+
"customizable_pct": round(customizable_count / total_ui_criteria, 2) if total_ui_criteria else None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ui_archetype = None
|
|
163
|
+
conventions_path = pcp_dir / "design_conventions.yaml"
|
|
164
|
+
if conventions_path.exists():
|
|
165
|
+
ui_archetype = (_load_yaml(conventions_path) or {}).get("ui_archetype")
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"modules": modules,
|
|
169
|
+
"rung_counts": rung_counts,
|
|
170
|
+
"undetermined": undetermined,
|
|
171
|
+
"nav_analysis": nav,
|
|
172
|
+
"total_ui_criteria": total_ui_criteria,
|
|
173
|
+
"nav_depth": nav_depth_summary,
|
|
174
|
+
"customization": customization_summary,
|
|
175
|
+
"ui_archetype": ui_archetype,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _render_markdown(data: dict, timestamp: str) -> str:
|
|
180
|
+
lines = [
|
|
181
|
+
"# Design Audit — Feature Exposure Ladder",
|
|
182
|
+
"",
|
|
183
|
+
f"_Auto-generated by `pcp design-audit` at {timestamp}. Never hand-edit — "
|
|
184
|
+
"the rationale lives in each criterion's design_justification; this is a "
|
|
185
|
+
"rollup view, not a second place to author it._",
|
|
186
|
+
"",
|
|
187
|
+
"PCP Design lifecycle, stage 5 (Audit/rollup). Maps to Google HEART's Adoption "
|
|
188
|
+
"pillar. Rungs 1-3 are MEASURED from the built UI — pages discovered from the "
|
|
189
|
+
"front end's own entry config, edges from its links, depth by shortest path from "
|
|
190
|
+
"the entry page. Only rung 4 consults a declaration, because whether a screen is "
|
|
191
|
+
"framed as a real job-to-be-done is a property of the writing, not the artifact.",
|
|
192
|
+
"",
|
|
193
|
+
"## Rung Distribution",
|
|
194
|
+
"",
|
|
195
|
+
"| Rung | Label | Criteria |",
|
|
196
|
+
"|---|---|---|",
|
|
197
|
+
]
|
|
198
|
+
for rung, label in RUNG_LABEL.items():
|
|
199
|
+
lines.append(f"| {rung} | {label} | {data['rung_counts'].get(rung, 0)} |")
|
|
200
|
+
lines.append("")
|
|
201
|
+
|
|
202
|
+
# "Could not measure" is its own row and never folded into rung 1. The
|
|
203
|
+
# previous version classified a missing `design_justification` as
|
|
204
|
+
# "Built, Hidden" and reported 101 of them on Project O -- a
|
|
205
|
+
# statement about absent paperwork dressed as a discoverability crisis.
|
|
206
|
+
nav = data.get("nav_analysis") or {}
|
|
207
|
+
undet = data.get("undetermined", 0)
|
|
208
|
+
if undet:
|
|
209
|
+
lines += [
|
|
210
|
+
f"**{undet} UI-facing criteria could not be placed on the ladder.** This is "
|
|
211
|
+
"NOT a finding about the product — it means the criterion could not be tied "
|
|
212
|
+
"to a screen, so its discoverability was never measured.",
|
|
213
|
+
"",
|
|
214
|
+
]
|
|
215
|
+
if nav.get("available"):
|
|
216
|
+
lines += [
|
|
217
|
+
f"The app itself WAS measured: `{nav.get('ui_root')}` — "
|
|
218
|
+
f"{len(nav.get('pages', []))} page(s), entry `{nav.get('entry')}`, "
|
|
219
|
+
f"{len(nav.get('unreachable', []))} unreachable. What is missing is the "
|
|
220
|
+
"link from criterion to screen: declare `target` on a UI-facing criterion "
|
|
221
|
+
"and it becomes measurable.",
|
|
222
|
+
"",
|
|
223
|
+
]
|
|
224
|
+
else:
|
|
225
|
+
lines += [
|
|
226
|
+
f"No front end could be measured either ({nav.get('reason', 'unknown')}), "
|
|
227
|
+
"so nothing here is a claim about discoverability.",
|
|
228
|
+
"",
|
|
229
|
+
]
|
|
230
|
+
if nav.get("available") and nav.get("unreachable"):
|
|
231
|
+
lines += [
|
|
232
|
+
"**Unreachable pages** (exist in the build, no path from the entry page): "
|
|
233
|
+
+ ", ".join(f"`{p}`" for p in nav["unreachable"]),
|
|
234
|
+
"",
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
nd = data["nav_depth"]
|
|
238
|
+
lines += ["## Navigation Depth (clicks from entry point)", ""]
|
|
239
|
+
if nd["declared"]:
|
|
240
|
+
lines.append(
|
|
241
|
+
f"Declared on {nd['declared']} UI-facing criteria ({nd['missing']} missing) — "
|
|
242
|
+
f"max {nd['max']}, avg {nd['avg']}, {nd['within_threshold_pct']:.0%} within the "
|
|
243
|
+
f"{nd['threshold']}-click threshold. Self-declared, not computed from a real routing "
|
|
244
|
+
"graph — see CTRL-025."
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
lines.append(f"_No criteria declare nav_depth yet ({nd['missing']} missing)._")
|
|
248
|
+
lines.append("")
|
|
249
|
+
|
|
250
|
+
cz = data["customization"]
|
|
251
|
+
lines += ["## Feature Customization", ""]
|
|
252
|
+
if cz["total_ui_criteria"]:
|
|
253
|
+
lines.append(
|
|
254
|
+
f"{cz['customizable_count']}/{cz['total_ui_criteria']} UI-facing criteria "
|
|
255
|
+
f"({cz['customizable_pct']:.0%}) declare `customizable: true` — see CTRL-026 for the "
|
|
256
|
+
"structural check on those declarations."
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
lines.append("_No UI-facing criteria yet._")
|
|
260
|
+
lines.append("")
|
|
261
|
+
|
|
262
|
+
lines += ["## Top Menu Bar Convention", ""]
|
|
263
|
+
if data["ui_archetype"] == "desktop_app":
|
|
264
|
+
lines.append("`ui_archetype: desktop_app` — CTRL-027 checks for File/Edit/View/Help-style "
|
|
265
|
+
"menus; see provenance.md / telemetry for the latest result.")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f"`ui_archetype: {data['ui_archetype'] or 'web_app (default)'}` — "
|
|
268
|
+
"menu-bar convention check inert (desktop_app only).")
|
|
269
|
+
lines.append("")
|
|
270
|
+
|
|
271
|
+
if not data["modules"]:
|
|
272
|
+
lines.append("_No UI-facing criteria found yet._")
|
|
273
|
+
return "\n".join(lines)
|
|
274
|
+
|
|
275
|
+
for m in data["modules"]:
|
|
276
|
+
lines.append(f"## Module: `{m['module']}`")
|
|
277
|
+
lines.append("")
|
|
278
|
+
lines.append("| Criterion | Rung | JTBD Framing | Nav Depth | Customizable |")
|
|
279
|
+
lines.append("|---|---|---|---|---|")
|
|
280
|
+
for c in m["criteria"]:
|
|
281
|
+
# rung None = the criterion's screen could not be identified, so
|
|
282
|
+
# discoverability was never measured. It gets its own cell rather
|
|
283
|
+
# than borrowing rung 1's "Built, Hidden" label and its ⚠.
|
|
284
|
+
rung = c["rung"]
|
|
285
|
+
flag = " ⚠" if rung == 1 else ""
|
|
286
|
+
rung_cell = f"{rung} ({RUNG_LABEL[rung]})" if rung is not None else "— (not measured)"
|
|
287
|
+
nav_depth_cell = c["nav_depth"] if c["nav_depth"] is not None else "—"
|
|
288
|
+
customizable_cell = "✓" if c["customizable"] else "—"
|
|
289
|
+
lines.append(
|
|
290
|
+
f"| {c['id']}: {c['description']}{flag} | {rung_cell} | "
|
|
291
|
+
f"{c['jtbd_framing'] or '—'} | {nav_depth_cell} | {customizable_cell} |"
|
|
292
|
+
)
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
return "\n".join(lines)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def write_design_audit(pcp_dir: Path) -> Path:
|
|
299
|
+
data = build_design_audit(pcp_dir)
|
|
300
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
301
|
+
md = _render_markdown(data, timestamp)
|
|
302
|
+
out = pcp_dir / "design_audit.md"
|
|
303
|
+
out.write_text(md)
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@click.command(name="design-audit")
|
|
308
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
309
|
+
@click.option("--json", "output_json", is_flag=True, help="Print raw JSON instead of writing design_audit.md.")
|
|
310
|
+
def design_audit(project_path: str | None, output_json: bool):
|
|
311
|
+
"""Feature Exposure Ladder — roll up how discoverable UI-facing criteria actually are."""
|
|
312
|
+
try:
|
|
313
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
314
|
+
except NoPCPDir as e:
|
|
315
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
316
|
+
sys.exit(2)
|
|
317
|
+
|
|
318
|
+
if output_json:
|
|
319
|
+
click.echo(json.dumps(build_design_audit(pcp_dir), indent=2, default=str))
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
out_path = write_design_audit(pcp_dir)
|
|
323
|
+
console.print(f"[green]Design audit written[/green] -> {out_path.relative_to(pcp_dir.parent)}")
|