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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
@@ -0,0 +1,64 @@
1
+ """pcp escalations — view/acknowledge the escalation ledger."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
11
+ from pcp import escalations
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.command("escalations")
17
+ @click.option("--path", "project_path", type=click.Path(), default=None)
18
+ @click.option("--ack", "ack_target", default=None, metavar="MODULE/CRITERION",
19
+ help="Acknowledge an escalation (marks a human has seen it — distinct from resolving it).")
20
+ def escalations_cmd(project_path: str | None, ack_target: str | None):
21
+ """List escalations, staleness state, and MTTA; acknowledge with --ack."""
22
+ try:
23
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
24
+ except NoPCPDir as e:
25
+ console.print(f"[red]Error:[/red] {e}")
26
+ sys.exit(2)
27
+
28
+ if ack_target:
29
+ if "/" not in ack_target:
30
+ console.print("[red]Error:[/red] --ack expects MODULE/CRITERION (e.g. auth/A001)")
31
+ sys.exit(2)
32
+ module, criterion_id = ack_target.split("/", 1)
33
+ count = escalations.acknowledge(pcp_dir, module, criterion_id)
34
+ if count:
35
+ console.print(f"[green]✓[/green] acknowledged {count} escalation(s) for {ack_target}")
36
+ else:
37
+ console.print(f"[yellow]No un-acknowledged escalations found for {ack_target}[/yellow]")
38
+ return
39
+
40
+ entries = escalations.load(pcp_dir)
41
+ if not entries:
42
+ console.print("[dim]No escalations recorded.[/dim]")
43
+ return
44
+
45
+ stale = {(s.get("module"), s.get("criterion_id"), s.get("timestamp")): s.get("state")
46
+ for s in escalations.find_stale(pcp_dir)}
47
+ table = Table(title="Escalations")
48
+ for col in ("When", "Module/Criterion", "Category", "Acked", "State"):
49
+ table.add_column(col)
50
+ for e in entries:
51
+ key = (e.get("module"), e.get("criterion_id"), e.get("timestamp"))
52
+ state = stale.get(key, "")
53
+ style = "red bold" if state else ""
54
+ table.add_row(
55
+ e.get("timestamp", ""), f"{e.get('module')}/{e.get('criterion_id')}",
56
+ e.get("category", ""), "yes" if e.get("acknowledged_at") else "no",
57
+ state or "ok", style=style or None,
58
+ )
59
+ console.print(table)
60
+ mtta = escalations.mtta_hours(pcp_dir)
61
+ if mtta is not None:
62
+ console.print(f"MTTA (median time-to-acknowledge): {mtta}h")
63
+ else:
64
+ console.print("[dim]MTTA: no escalation has ever been acknowledged.[/dim]")
pcp/commands/gate.py ADDED
@@ -0,0 +1,209 @@
1
+ """pcp gate — Layer 2 PR advisory gate (LLM alignment scoring)."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+ from rich.console import Console
11
+
12
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
13
+ from pcp.schema.validator import load_yaml
14
+ from pcp.llm import client as llm
15
+
16
+ console = Console()
17
+
18
+ SYSTEM_PROMPT = """\
19
+ You are a program-context auditor reviewing a git diff for a pull request.
20
+ Score how well this PR moves the codebase toward the program objective and target state.
21
+
22
+ Output ONLY valid JSON — no prose, no markdown, no code fences.
23
+
24
+ Output schema:
25
+ {
26
+ "alignment_score": 0.0,
27
+ "summary": "one sentence",
28
+ "advances": ["what this PR does well vs objective"],
29
+ "regressions": ["what this PR moves away from objective or target_state"],
30
+ "llm_rule_violations": ["ci_rules.yaml llm_semantic rule violations"],
31
+ "recommendation": "merge | review | block"
32
+ }
33
+
34
+ alignment_score: 0.0 (moves away from objective) to 1.0 (perfectly aligned).
35
+ recommendation: 'merge' (score >= 0.7, no regressions), 'review' (score 0.4-0.7 or regressions),
36
+ 'block' (score < 0.4 or contradicts objective). Advisory only — never actually blocks merge.
37
+ """
38
+
39
+
40
+ def _get_diff(base: str) -> str:
41
+ result = subprocess.run(
42
+ ["git", "diff", f"{base}...HEAD"],
43
+ capture_output=True, text=True,
44
+ )
45
+ if result.returncode != 0:
46
+ raise RuntimeError(f"git diff failed: {result.stderr.strip()}")
47
+ return result.stdout[:12000] # cap at 12k chars for LLM context
48
+
49
+
50
+ def _load_llm_rules(pcp_dir: Path) -> list[dict]:
51
+ ci_rules_path = pcp_dir / "ci_rules.yaml"
52
+ if not ci_rules_path.exists():
53
+ return []
54
+ data = load_yaml(ci_rules_path)
55
+ return [r for r in data.get("rules", []) if r.get("check") == "llm_semantic"]
56
+
57
+
58
+ def _build_prompt(
59
+ objective: str,
60
+ target_state: str,
61
+ current_state: str,
62
+ diff: str,
63
+ llm_rules: list[dict],
64
+ ) -> str:
65
+ parts = [f"## Program Objective\n\n{objective}\n"]
66
+
67
+ if target_state:
68
+ parts.append(f"## Target State\n\n{target_state}\n")
69
+
70
+ if current_state:
71
+ parts.append(f"## Current State (before this PR)\n\n{current_state}\n")
72
+
73
+ if llm_rules:
74
+ parts.append("## Advisory Rules (llm_semantic from ci_rules.yaml)\n")
75
+ for r in llm_rules:
76
+ parts.append(f"- [{r['id']}] {r['name']}: {r.get('description', '')}")
77
+ parts.append("")
78
+
79
+ parts.append(f"## PR Diff\n\n```diff\n{diff}\n```\n")
80
+ parts.append("Score this PR's alignment with the objective and target state.")
81
+ return "\n".join(parts)
82
+
83
+
84
+ def _render_markdown_comment(result: dict, base: str) -> str:
85
+ score = result.get("alignment_score", 0.0)
86
+ rec = result.get("recommendation", "review")
87
+ emoji = {"merge": "✅", "review": "⚠️", "block": "🚫"}.get(rec, "⚠️")
88
+
89
+ lines = [
90
+ "## PCP Gate Report",
91
+ "",
92
+ f"**Alignment score:** {score:.0%} {emoji} `{rec.upper()}`",
93
+ f"**Base:** `{base}`",
94
+ "",
95
+ f"_{result.get('summary', '')}_",
96
+ "",
97
+ ]
98
+
99
+ if result.get("advances"):
100
+ lines += ["**What this PR advances:**", ""]
101
+ for a in result["advances"]:
102
+ lines.append(f"- ✓ {a}")
103
+ lines.append("")
104
+
105
+ if result.get("regressions"):
106
+ lines += ["**Regressions / concerns:**", ""]
107
+ for r in result["regressions"]:
108
+ lines.append(f"- ⚠ {r}")
109
+ lines.append("")
110
+
111
+ if result.get("llm_rule_violations"):
112
+ lines += ["**Advisory rule violations:**", ""]
113
+ for v in result["llm_rule_violations"]:
114
+ lines.append(f"- {v}")
115
+ lines.append("")
116
+
117
+ lines += [
118
+ "---",
119
+ "_This is advisory only — it does not block merge. "
120
+ "Generated by [PCP](https://pcp-protocol.com) `pcp gate`._",
121
+ ]
122
+ return "\n".join(lines)
123
+
124
+
125
+ @click.command()
126
+ @click.option("--base", default="main", show_default=True,
127
+ help="Base branch to diff against.")
128
+ @click.option("--json", "output_json", is_flag=True,
129
+ help="Output raw JSON (for GitHub Action consumption).")
130
+ @click.option("--markdown", "output_markdown", is_flag=True,
131
+ help="Output GitHub-flavored markdown PR comment.")
132
+ @click.option("--path", "project_path", type=click.Path(), default=None)
133
+ def gate(base: str, output_json: bool, output_markdown: bool, project_path: str | None):
134
+ """Layer 2 PR gate — LLM alignment score (advisory). Never hard-blocks."""
135
+ try:
136
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
137
+ except NoPCPDir as e:
138
+ console.print(f"[red]Error:[/red] {e}")
139
+ sys.exit(2)
140
+
141
+ try:
142
+ diff = _get_diff(base)
143
+ except RuntimeError as e:
144
+ console.print(f"[red]Error:[/red] {e}")
145
+ sys.exit(2)
146
+
147
+ if not diff.strip():
148
+ console.print("[dim]No diff vs base branch — nothing to gate.[/dim]")
149
+ sys.exit(0)
150
+
151
+ objective = (pcp_dir / "objective.md").read_text() if (pcp_dir / "objective.md").exists() else ""
152
+ target_state = (pcp_dir / "target_state.md").read_text() if (pcp_dir / "target_state.md").exists() else ""
153
+ current_state = (pcp_dir / "current_state.md").read_text() if (pcp_dir / "current_state.md").exists() else ""
154
+ llm_rules = _load_llm_rules(pcp_dir)
155
+
156
+ if not output_json and not output_markdown:
157
+ console.print(f"[dim]Scoring PR alignment vs {base}...[/dim]")
158
+
159
+ try:
160
+ result = llm.call_json(
161
+ SYSTEM_PROMPT,
162
+ _build_prompt(objective, target_state, current_state, diff, llm_rules),
163
+ model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="gate",
164
+ )
165
+ except RuntimeError as e:
166
+ console.print(f"[red]Error:[/red] {e}")
167
+ sys.exit(2)
168
+ except ValueError as e:
169
+ console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
170
+ sys.exit(2)
171
+
172
+ from pcp import telemetry
173
+ changed_files_result = subprocess.run(
174
+ ["git", "diff", "--name-only", f"{base}...HEAD"], capture_output=True, text=True,
175
+ )
176
+ changed_files = [f.strip() for f in changed_files_result.stdout.splitlines() if f.strip()]
177
+ telemetry.record(
178
+ pcp_dir, cycle="qa", cycle_number=None, check="gate", control_id="CTRL-006",
179
+ module=None, submodule=None, criterion_id=None, files=changed_files,
180
+ result="block" if result.get("recommendation") == "block" else "pass",
181
+ errors=result.get("regressions", []) + result.get("llm_rule_violations", []),
182
+ error_count=len(result.get("regressions", [])) + len(result.get("llm_rule_violations", [])),
183
+ )
184
+
185
+ if output_json:
186
+ click.echo(json.dumps(result, indent=2))
187
+ sys.exit(0)
188
+
189
+ if output_markdown:
190
+ click.echo(_render_markdown_comment(result, base))
191
+ sys.exit(0)
192
+
193
+ # Rich terminal output
194
+ score = result.get("alignment_score", 0.0)
195
+ rec = result.get("recommendation", "review")
196
+ color = {"merge": "green", "review": "yellow", "block": "red"}.get(rec, "yellow")
197
+
198
+ console.print(f"\n[bold]Alignment:[/bold] [{color}]{score:.0%}[/{color}] [{color}]{rec.upper()}[/{color}]")
199
+ console.print(f"[dim]{result.get('summary', '')}[/dim]\n")
200
+
201
+ for a in result.get("advances", []):
202
+ console.print(f" [green]✓[/green] {a}")
203
+ for r in result.get("regressions", []):
204
+ console.print(f" [yellow]⚠[/yellow] {r}")
205
+ for v in result.get("llm_rule_violations", []):
206
+ console.print(f" [yellow]⚠[/yellow] {v}")
207
+
208
+ console.print("\n[dim]Advisory only — does not block merge.[/dim]")
209
+ sys.exit(0)
@@ -0,0 +1,404 @@
1
+ """pcp import — brownfield project onboarding.
2
+
3
+ Reverse-engineers .pcp/ from an existing codebase using:
4
+ 1. PM description → objective.md
5
+ 2. Stack detection + source file inventory
6
+ 3. AST-based import graph → cluster detection (natural module boundaries)
7
+ 4. Cross-cluster coupling analysis
8
+ 5. Draft .pcp/ scaffold with _generated: true markers
9
+ 6. baseline_violations.yaml from existing pcp check violations
10
+ """
11
+
12
+ import json
13
+ import sys
14
+ from collections import defaultdict
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ import click
19
+ import yaml
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+ from rich import print as rprint
23
+
24
+ from pcp.discovery.scanner import detect_stack, collect_source_files, detect_entry_points, read_manifest_deps
25
+ from pcp.discovery.graph import build_dependency_graph
26
+ from pcp.discovery.clusters import detect_clusters, compute_coupling_matrix
27
+ from pcp.llm import client as llm
28
+ from pcp.commands.kickoff import _normalize_spec, _normalize_acceptance
29
+
30
+ console = Console()
31
+
32
+ # Brownfield modules get the same v2.0 schema (logic_tier/build_vs_buy
33
+ # enforcement) a fresh `pcp kickoff` already produces for a new project --
34
+ # previously this command wrote the older, ungated v1 acceptance shape via
35
+ # its own terse prompt, so every post-import feature silently bypassed the
36
+ # Logic-Tier Selection framework the same way `pcp pm` did before that gap
37
+ # was closed. _normalize_spec/_normalize_acceptance (kickoff.py) are reused
38
+ # unchanged as the safety net against an LLM-invented enum value, same
39
+ # coercion posture kickoff itself relies on.
40
+ GENERATE_MODULE_PROMPT = """\
41
+ You are a software architect generating draft PCP module specs for an existing (brownfield) codebase.
42
+
43
+ Given a PM description of the whole project, a detected cluster of files (a natural module \
44
+ boundary from import-graph analysis), and that cluster's cross-module import edges, generate \
45
+ BOTH a spec.yaml and an acceptance.yaml for this module -- schema version 2.0, the same shape \
46
+ a fresh `pcp kickoff` produces for a new project.
47
+
48
+ Acceptance criteria for a brownfield module are characterization/decoupling work, not new \
49
+ features: always include exactly one BF_001 characterization-test criterion ("write \
50
+ characterization tests -- golden master, no code changes"), then one BF_00N decouple criterion \
51
+ per cross-cluster import edge given below (up to 3), each describing removing that direct \
52
+ coupling and routing through an interface instead.
53
+
54
+ Output ONLY valid JSON, no prose, no code fences. Format:
55
+ {
56
+ "spec": {
57
+ "module": "<cluster_name>",
58
+ "description": "<one sentence: what this module actually does, inferred from its files>",
59
+ "dependencies": [<other module names this cluster imports to/from, from the edges given>],
60
+ "constraints": ["<key constraint visible from the code>"],
61
+ "build_vs_buy": {"decision": "not_applicable", "rationale": "brownfield characterization module, no whole-module tool-adoption choice here", "candidates_considered": []}
62
+ },
63
+ "acceptance": {
64
+ "criteria": [
65
+ {
66
+ "id": "BF_001",
67
+ "description": "Write characterization tests for <cluster_name> (golden master -- no code changes)",
68
+ "check": "test_passes",
69
+ "status": "pending",
70
+ "logic_tier": 1,
71
+ "build_vs_buy": {"decision": "build_fresh", "rationale": "one-sentence rationale", "candidates_considered": []},
72
+ "depends_on": []
73
+ }
74
+ ]
75
+ }
76
+ }
77
+
78
+ Every criterion MUST also declare depends_on: a list of OTHER criterion ids in this module that must be built first. Default to an empty list -- most brownfield characterization/decoupling criteria are independent by nature (each targets a different file or coupling edge). Only list a real id when this criterion would break or be meaningless without that other one existing first.
79
+ """
80
+
81
+
82
+ def _default_module_shape(cluster_name: str, files: list[str], deps: list[str]) -> tuple[dict, dict]:
83
+ """Deterministic fallback used both when the LLM call fails and for
84
+ --skip-specs -- same v2.0 shape either way, so a project's module set
85
+ is never a mix of gated and ungated modules depending on which path
86
+ happened to generate which one."""
87
+ spec = {
88
+ "module": cluster_name,
89
+ "description": f"Auto-detected cluster ({len(files)} files) -- description not yet reviewed",
90
+ "_generated": True,
91
+ "dependencies": deps,
92
+ "constraints": [],
93
+ }
94
+ acceptance = {
95
+ "criteria": [
96
+ {
97
+ "id": "BF_001",
98
+ "description": f"Write characterization tests for {cluster_name} (golden master — no code changes)",
99
+ "check": "test_passes",
100
+ "status": "pending",
101
+ "logic_tier": 1,
102
+ "build_vs_buy": {
103
+ "decision": "build_fresh",
104
+ "rationale": "Characterization tests are project-specific by definition -- nothing to reuse.",
105
+ "candidates_considered": [],
106
+ },
107
+ "depends_on": [],
108
+ }
109
+ ]
110
+ }
111
+ for i, target in enumerate(deps[:3], 2):
112
+ acceptance["criteria"].append({
113
+ "id": f"BF_00{i}",
114
+ "description": f"Remove direct {cluster_name}→{target} coupling — route through interface",
115
+ "check": "test_passes",
116
+ "status": "pending",
117
+ "logic_tier": 1,
118
+ "build_vs_buy": {
119
+ "decision": "build_fresh",
120
+ "rationale": "Decoupling work is project-specific by definition -- nothing to reuse.",
121
+ "candidates_considered": [],
122
+ },
123
+ "depends_on": [],
124
+ })
125
+ return spec, acceptance
126
+
127
+
128
+ def _generate_module(
129
+ cluster_name: str,
130
+ files: list[str],
131
+ cross_edges: dict[tuple[str, str], int],
132
+ pm_description: str,
133
+ pcp_dir: Path | None = None,
134
+ ) -> tuple[dict, dict]:
135
+ """Returns (spec, acceptance, coercion_warnings), spec/acceptance both
136
+ v2.0-shaped and already normalized via kickoff.py's own coercion
137
+ functions -- callers append coercion_warnings to their own list."""
138
+ deps = sorted({
139
+ b for (a, b), count in cross_edges.items()
140
+ if a == cluster_name and count > 0
141
+ } | {
142
+ a for (a, b), count in cross_edges.items()
143
+ if b == cluster_name and count > 0
144
+ })
145
+
146
+ user_prompt = f"""PM description: {pm_description}
147
+
148
+ Cluster name: {cluster_name}
149
+ Files in cluster ({len(files)} files):
150
+ {chr(10).join(f" {f}" for f in files[:30])}
151
+ {" ... (truncated)" if len(files) > 30 else ""}
152
+
153
+ Cross-cluster imports to/from: {deps or ["none detected"]}
154
+ """
155
+ try:
156
+ result = llm.call_json(GENERATE_MODULE_PROMPT, user_prompt, pcp_dir=pcp_dir, command="import-generate-module")
157
+ spec = result["spec"]
158
+ acceptance = result["acceptance"]
159
+ if not isinstance(spec, dict) or not isinstance(acceptance, dict):
160
+ raise ValueError("spec/acceptance not a dict")
161
+ spec["module"] = cluster_name
162
+ spec["_generated"] = True
163
+ acceptance.setdefault("criteria", [])
164
+ except Exception:
165
+ spec, acceptance = _default_module_shape(cluster_name, files, deps)
166
+
167
+ spec["version"] = "2.0"
168
+ acceptance["module"] = cluster_name
169
+ acceptance["version"] = "2.0"
170
+ warnings = _normalize_spec(spec, cluster_name)
171
+ warnings += _normalize_acceptance(acceptance, cluster_name)
172
+ return spec, acceptance, warnings
173
+
174
+
175
+ def _coupling_score(cross_edges: dict, clusters: dict) -> float:
176
+ total_cluster_pairs = len(clusters) * (len(clusters) - 1)
177
+ if total_cluster_pairs == 0:
178
+ return 1.0
179
+ coupled_pairs = len({(min(a, b), max(a, b)) for a, b in cross_edges if cross_edges[(a, b)] > 0})
180
+ return round(1.0 - (coupled_pairs / total_cluster_pairs), 2)
181
+
182
+
183
+ @click.command(name="import")
184
+ @click.argument("description")
185
+ @click.option("--path", "project_path", type=click.Path(), default=None,
186
+ help="Project root (default: cwd).")
187
+ @click.option("--dry-run", is_flag=True, help="Print discovery results, write nothing.")
188
+ @click.option("--skip-specs", is_flag=True, help="Skip LLM spec generation (faster, manual specs).")
189
+ def import_project(description: str, project_path: str | None, dry_run: bool, skip_specs: bool):
190
+ """Onboard an existing codebase into PCP.
191
+
192
+ DESCRIPTION: one paragraph describing what this project does.
193
+
194
+ Runs AST-based import analysis, detects module clusters, generates
195
+ draft .pcp/ structure for PM review.
196
+ """
197
+ root = Path(project_path).resolve() if project_path else Path.cwd().resolve()
198
+ pcp_dir = root / ".pcp"
199
+
200
+ if pcp_dir.exists() and not dry_run:
201
+ console.print("[yellow]⚠ .pcp/ already exists.[/yellow]")
202
+ console.print("Use [bold]pcp init[/bold] to reinitialise, or [bold]--dry-run[/bold] to preview discovery only.")
203
+ sys.exit(1)
204
+
205
+ console.print(f"\n[bold]PCP Import[/bold] — {root.name}")
206
+ console.print(f"[dim]{root}[/dim]\n")
207
+
208
+ # ── 1. Stack detection ────────────────────────────────────────────────────
209
+ console.print("[dim]Detecting stack...[/dim]")
210
+ stack = detect_stack(root)
211
+ entry_points = detect_entry_points(root, stack)
212
+ manifest_deps = read_manifest_deps(root)
213
+ console.print(f" Stack: {', '.join(stack)}")
214
+ console.print(f" Entry points: {', '.join(entry_points) or 'none detected'}")
215
+
216
+ # ── 2. Source file inventory ──────────────────────────────────────────────
217
+ console.print("[dim]Collecting source files...[/dim]")
218
+ files = collect_source_files(root, stack)
219
+ console.print(f" Source files: {len(files)}")
220
+ if not files:
221
+ console.print("[red]No source files found. Check --path or stack detection.[/red]")
222
+ sys.exit(1)
223
+
224
+ # ── 3. Build import graph ─────────────────────────────────────────────────
225
+ console.print("[dim]Building import dependency graph...[/dim]")
226
+ graph = build_dependency_graph(files, root)
227
+ total_edges = sum(len(v) for v in graph.values())
228
+ console.print(f" Import edges: {total_edges}")
229
+
230
+ # ── 4. Cluster detection ──────────────────────────────────────────────────
231
+ console.print("[dim]Detecting module clusters...[/dim]")
232
+ clusters = detect_clusters(graph, files, root)
233
+ cross_edges = compute_coupling_matrix(graph, clusters)
234
+ coupling = _coupling_score(cross_edges, clusters)
235
+
236
+ # ── 5. Print cluster map ──────────────────────────────────────────────────
237
+ console.print(f"\n[bold]Clusters detected:[/bold] {len(clusters)}")
238
+ table = Table(show_header=True, header_style="bold")
239
+ table.add_column("Cluster", style="cyan")
240
+ table.add_column("Files", justify="right")
241
+ table.add_column("Cross-cluster imports", style="yellow")
242
+
243
+ for cluster_name, cluster_files in sorted(clusters.items()):
244
+ outbound = sum(
245
+ count for (a, b), count in cross_edges.items() if a == cluster_name
246
+ )
247
+ table.add_row(cluster_name, str(len(cluster_files)), str(outbound) if outbound else "—")
248
+ console.print(table)
249
+
250
+ coupling_color = "green" if coupling >= 0.7 else "yellow" if coupling >= 0.5 else "red"
251
+ console.print(f"\n[bold]Coupling score:[/bold] [{coupling_color}]{coupling:.0%}[/{coupling_color}] "
252
+ f"[dim](1.0 = fully decoupled)[/dim]")
253
+
254
+ # Print coupling violations
255
+ if cross_edges:
256
+ violations = sorted(cross_edges.items(), key=lambda x: -x[1])[:10]
257
+ console.print("\n[bold yellow]Cross-cluster dependencies (coupling violations):[/bold yellow]")
258
+ for (a, b), count in violations:
259
+ console.print(f" {a} ↔ {b}: {count} import{'s' if count != 1 else ''}")
260
+
261
+ if dry_run:
262
+ console.print("\n[dim]--dry-run: no files written.[/dim]")
263
+ return
264
+
265
+ # ── 6. Write .pcp/ scaffold ───────────────────────────────────────────────
266
+ console.print("\n[dim]Writing .pcp/ scaffold...[/dim]")
267
+ pcp_dir.mkdir(exist_ok=True)
268
+ (pcp_dir / "strategy").mkdir(exist_ok=True)
269
+ (pcp_dir / "strategy" / "modules").mkdir(exist_ok=True)
270
+
271
+ # objective.md
272
+ (pcp_dir / "objective.md").write_text(
273
+ f"# Objective\n\n{description}\n\n"
274
+ f"---\n_Imported from existing codebase on "
275
+ f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}._\n"
276
+ )
277
+
278
+ # architecture.md
279
+ dep_lines = []
280
+ for lang, deps in manifest_deps.items():
281
+ if deps:
282
+ dep_lines.append(f"- **{lang}**: {', '.join(deps[:10])}")
283
+ (pcp_dir / "architecture.md").write_text(
284
+ f"# Architecture\n\n"
285
+ f"## Stack\n{', '.join(stack)}\n\n"
286
+ f"## Entry Points\n"
287
+ + ("\n".join(f"- `{e}`" for e in entry_points) or "- (none detected)") + "\n\n"
288
+ f"## Dependencies\n"
289
+ + ("\n".join(dep_lines) or "- (see manifest)") + "\n\n"
290
+ f"---\n_Auto-detected. Review and update._\n"
291
+ )
292
+
293
+ # decomposition.md
294
+ cluster_lines = "\n".join(
295
+ f"- **{name}** ({len(fs)} files) — _review and describe_"
296
+ for name, fs in sorted(clusters.items())
297
+ )
298
+ (pcp_dir / "strategy" / "decomposition.md").write_text(
299
+ f"# Module Decomposition\n\n"
300
+ f"Auto-detected from import graph analysis. "
301
+ f"Review cluster boundaries and update descriptions.\n\n"
302
+ f"{cluster_lines}\n\n"
303
+ f"---\n_Generated by `pcp import`. Human review required._\n"
304
+ )
305
+
306
+ # dependency_map.md
307
+ dep_map_lines = []
308
+ for (a, b), count in sorted(cross_edges.items(), key=lambda x: -x[1]):
309
+ dep_map_lines.append(f"- {a} → {b}: {count} import edge(s) — **decouple in Wave 0**")
310
+ (pcp_dir / "strategy" / "dependency_map.md").write_text(
311
+ f"# Dependency Map\n\n"
312
+ f"Cross-module dependencies detected by import analysis.\n"
313
+ f"Coupling score: {coupling:.0%}\n\n"
314
+ + ("\n".join(dep_map_lines) or "No cross-cluster dependencies detected.") + "\n\n"
315
+ f"---\n_Generated by `pcp import`._\n"
316
+ )
317
+
318
+ # Module spec + acceptance files -- v2.0 schema, same logic_tier/
319
+ # build_vs_buy enforcement `pcp kickoff` already gives a greenfield module.
320
+ all_coercion_warnings: list[str] = []
321
+ for cluster_name, cluster_files in clusters.items():
322
+ module_dir = pcp_dir / "strategy" / "modules" / cluster_name
323
+ module_dir.mkdir(exist_ok=True)
324
+
325
+ if skip_specs:
326
+ deps = sorted({b for (a, b), c in cross_edges.items() if a == cluster_name and c > 0}
327
+ | {a for (a, b), c in cross_edges.items() if b == cluster_name and c > 0})
328
+ spec, acceptance = _default_module_shape(cluster_name, cluster_files, deps)
329
+ spec["version"] = "2.0"
330
+ acceptance["module"] = cluster_name
331
+ acceptance["version"] = "2.0"
332
+ else:
333
+ spec, acceptance, warnings = _generate_module(
334
+ cluster_name, cluster_files, cross_edges, description, pcp_dir=pcp_dir,
335
+ )
336
+ all_coercion_warnings += warnings
337
+
338
+ (module_dir / "spec.yaml").write_text(
339
+ "# DRAFT — review and remove _generated: true when correct\n"
340
+ + yaml.dump(spec, default_flow_style=False, sort_keys=False)
341
+ )
342
+ (module_dir / "acceptance.yaml").write_text(
343
+ "# DRAFT — add PM-validated acceptance criteria here\n"
344
+ + yaml.dump(acceptance, default_flow_style=False, sort_keys=False)
345
+ )
346
+
347
+ if all_coercion_warnings:
348
+ console.print(f"[yellow]⚠ {len(all_coercion_warnings)} generated field(s) didn't match the schema, coerced to a safe default:[/yellow]")
349
+ for w in all_coercion_warnings:
350
+ console.print(f" {w}")
351
+
352
+ # baseline_violations.yaml (empty — pcp check --baseline fills this)
353
+ (pcp_dir / "baseline_violations.yaml").write_text(
354
+ "# Pre-existing violations — run `pcp check --baseline` to populate.\n"
355
+ "# These are excluded from hard gates until fixed in Wave 0.\n"
356
+ "violations: []\n"
357
+ "generated_at: null\n"
358
+ "total: 0\n"
359
+ )
360
+
361
+ # SDLC_phase.yaml
362
+ (pcp_dir / "SDLC_phase.yaml").write_text(
363
+ yaml.dump({
364
+ "phase": "brownfield-import",
365
+ "imported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
366
+ "stack": stack,
367
+ "cluster_count": len(clusters),
368
+ "coupling_score": coupling,
369
+ "next_action": "Review .pcp/strategy/modules/*/spec.yaml. Remove _generated: true when correct.",
370
+ }, default_flow_style=False)
371
+ )
372
+
373
+ # graphify export — cluster graph as JSON for /graphify consumption
374
+ graphify_data = {
375
+ "nodes": [
376
+ {"id": name, "type": "module", "file_count": len(fs)}
377
+ for name, fs in clusters.items()
378
+ ],
379
+ "edges": [
380
+ {"source": a, "target": b, "weight": count, "type": "imports"}
381
+ for (a, b), count in cross_edges.items()
382
+ ],
383
+ "metadata": {
384
+ "project": root.name,
385
+ "coupling_score": coupling,
386
+ "stack": stack,
387
+ }
388
+ }
389
+ (pcp_dir / "discovery_graph.json").write_text(json.dumps(graphify_data, indent=2))
390
+
391
+ # ── 7. Summary ────────────────────────────────────────────────────────────
392
+ console.print(f"\n[green]✓ .pcp/ scaffold written.[/green]")
393
+ console.print(f"\n[bold]Next steps:[/bold]")
394
+ console.print(" 1. Review module specs: [cyan].pcp/strategy/modules/*/spec.yaml[/cyan]")
395
+ console.print(" Remove [yellow]_generated: true[/yellow] from each when description is correct.")
396
+ console.print(" 2. Run baseline scan: [cyan]pcp check --baseline[/cyan]")
397
+ console.print(" Catalogs pre-existing violations → [yellow].pcp/baseline_violations.yaml[/yellow]")
398
+ console.print(" 3. Run strategy check: [cyan]pcp validate-strategy[/cyan]")
399
+ console.print(" Coverage + coupling gaps visible immediately.")
400
+ console.print(" 4. Visualise clusters: [cyan]/graphify .pcp/discovery_graph.json[/cyan]")
401
+ console.print(" Pass the graph file to graphify for knowledge graph view.")
402
+ console.print(f"\n [dim]Coupling score {coupling:.0%} — "
403
+ + ("Wave 0 decouple recommended before new features." if coupling < 0.7
404
+ else "Coupling acceptable. Proceed to Wave 1 features.") + "[/dim]")