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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/commands/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)}")