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/status.py ADDED
@@ -0,0 +1,180 @@
1
+ """pcp status — generate or refresh pcp.md governance snapshot."""
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, get_modules_dir, NoPCPDir
12
+ from pcp.schema.validator import validate_file, load_yaml
13
+ from pcp.pcp_status import write_pcp_md
14
+
15
+ console = Console()
16
+
17
+
18
+ def _load_modules_results(modules_dir: Path, project_root: Path) -> list[dict]:
19
+ """Reconstruct module results from existing current_state.md or live scan."""
20
+ results = []
21
+ for af in sorted(modules_dir.glob("*/acceptance.yaml")):
22
+ module_name = af.parent.name
23
+ data = load_yaml(af)
24
+ criteria_results = []
25
+ for c in data.get("criteria", []):
26
+ criteria_results.append({
27
+ "id": c["id"],
28
+ "description": c["description"],
29
+ "check": c.get("check", "manual"),
30
+ "status": c.get("status", "pending"),
31
+ "detail": "",
32
+ })
33
+ results.append({"module": module_name, "criteria": criteria_results})
34
+ return results
35
+
36
+
37
+ def _parse_from_current_state(current_state_path: Path) -> dict[str, str]:
38
+ """Parse status from current_state.md — avoids re-running all checks."""
39
+ if not current_state_path.exists():
40
+ return {}
41
+ statuses = {}
42
+ for line in current_state_path.read_text().splitlines():
43
+ m = re.match(r"- \[([ x])\] ([A-Z0-9_-]+/[A-Z][0-9]+):", line.strip())
44
+ if m:
45
+ statuses[m.group(2)] = "complete" if m.group(1) == "x" else "pending"
46
+ return statuses
47
+
48
+
49
+ PM_STATUS_SYSTEM_PROMPT = """\
50
+ You are an expert AI product manager.
51
+ Your task is to generate a plain-English project status report for a non-technical PM based on the provided project context and git history.
52
+
53
+ The status report must be user-friendly, high-level, and avoid code snippets, technical jargon, or raw config file content.
54
+ It must include:
55
+ 1. Current SDLC Phase and Completion % (calculated from criteria complete/total).
56
+ 2. This Week's Progress (derived from recent git commits and completed criteria).
57
+ 3. Current Blockers / PM Actions Needed (e.g. pending manual criteria, inputs/decisions needed).
58
+ 4. What's Next (what features/modules will be developed next).
59
+
60
+ Keep it concise, clear, and action-oriented. Use formatting like bold text and bullet points.
61
+ """
62
+
63
+
64
+ def _get_recent_commits(repo_path: Path) -> str:
65
+ import subprocess
66
+ result = subprocess.run(
67
+ ["git", "log", "--since=7.days.ago", "--oneline"],
68
+ capture_output=True,
69
+ text=True,
70
+ cwd=repo_path,
71
+ )
72
+ if result.returncode != 0:
73
+ return "No recent commits."
74
+ return result.stdout
75
+
76
+
77
+ @click.command()
78
+ @click.option("--path", "project_path", type=click.Path(), default=None)
79
+ @click.option("--rescan", is_flag=True,
80
+ help="Re-run full scan before writing pcp.md (slower but accurate).")
81
+ @click.option("--print", "print_only", is_flag=True,
82
+ help="Print pcp.md to stdout instead of writing file.")
83
+ @click.option("--pm", "pm_mode", is_flag=True,
84
+ help="Generate a plain-English status report for the PM.")
85
+ def status(project_path: str | None, rescan: bool, print_only: bool, pm_mode: bool):
86
+ """Generate or refresh pcp.md governance snapshot at project root.
87
+
88
+ By default reads from existing current_state.md (fast).
89
+ Use --rescan to re-evaluate all acceptance criteria first.
90
+ """
91
+ try:
92
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
93
+ except NoPCPDir as e:
94
+ console.print(f"[red]Error:[/red] {e}")
95
+ sys.exit(2)
96
+
97
+ project_root = pcp_dir.parent
98
+ modules_dir = get_modules_dir(pcp_dir)
99
+
100
+ if rescan:
101
+ # Invoke scan command logic inline
102
+ from pcp.commands.scan import _scan_module, _write_current_state, _load_prior_manual_status
103
+ current_state_path = pcp_dir / "current_state.md"
104
+ prior_manual = _load_prior_manual_status(current_state_path)
105
+ modules_results = []
106
+ for af in sorted(modules_dir.glob("*/acceptance.yaml")):
107
+ module_name = af.parent.name
108
+ result = _scan_module(module_name, af, project_root, prior_manual)
109
+ modules_results.append(result)
110
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
111
+ _write_current_state(pcp_dir, modules_results, timestamp)
112
+ else:
113
+ # Fast path: read from current_state.md
114
+ statuses = _parse_from_current_state(pcp_dir / "current_state.md")
115
+ modules_results = _load_modules_results(modules_dir, project_root)
116
+ # Apply parsed statuses
117
+ for m in modules_results:
118
+ for c in m["criteria"]:
119
+ key = f"{m['module'].upper()}/{c['id']}"
120
+ if key in statuses:
121
+ c["status"] = statuses[key]
122
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
123
+
124
+ total = sum(len(m["criteria"]) for m in modules_results)
125
+ complete = sum(1 for m in modules_results for c in m["criteria"] if c["status"] == "complete")
126
+
127
+ pcp_md_path = write_pcp_md(pcp_dir, modules_results, timestamp, total, complete)
128
+
129
+ if pm_mode:
130
+ from pcp.llm import client as llm
131
+ obj_text = (pcp_dir / "objective.md").read_text() if (pcp_dir / "objective.md").exists() else ""
132
+ current_state_text = (pcp_dir / "current_state.md").read_text() if (pcp_dir / "current_state.md").exists() else ""
133
+ sdlc_text = (pcp_dir / "SDLC_phase.yaml").read_text() if (pcp_dir / "SDLC_phase.yaml").exists() else ""
134
+ commits = _get_recent_commits(project_root)
135
+
136
+ score_pct = f"{complete}/{total} ({complete/total:.0%})" if total else "0/0 (0%)"
137
+
138
+ user_prompt = (
139
+ f"Objective:\n{obj_text}\n\n"
140
+ f"Current State & Completion:\nCompletion Score: {score_pct}\n{current_state_text}\n\n"
141
+ f"SDLC Phase Config:\n{sdlc_text}\n\n"
142
+ f"Recent Commits (last 7 days):\n{commits}\n"
143
+ )
144
+
145
+ console.print("[dim]Generating plain-English PM status report...[/dim]\n")
146
+ try:
147
+ report_text = llm.call(
148
+ PM_STATUS_SYSTEM_PROMPT, user_prompt,
149
+ model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="status-pm",
150
+ )
151
+ click.echo(report_text)
152
+ except Exception as e:
153
+ console.print(f"[red]Error generating PM report:[/red] {e}")
154
+ sys.exit(2)
155
+ return
156
+
157
+ if print_only:
158
+ click.echo(pcp_md_path.read_text())
159
+ return
160
+
161
+ score = complete / total if total else 0.0
162
+ color = "green" if score >= 0.8 else "yellow" if score >= 0.5 else "red"
163
+ console.print(f"[{color}]{complete}/{total} ({score:.0%})[/{color}] → pcp.md")
164
+
165
+ # Escalation-acknowledgment watchdog — an escalation recorded but never
166
+ # acted on must stay loudly visible, not buried in escalations.yaml.
167
+ from pcp import escalations
168
+ for e in escalations.find_stale(pcp_dir):
169
+ console.print(
170
+ f"[red bold]STALE ESCALATION [{e.get('state', 'unacked')}]:[/red bold] "
171
+ f"{e.get('module')}/{e.get('criterion_id')} ({e.get('category', 'uncategorized')}) "
172
+ f"waiting {e.get('age_hours')}h — ack: pcp escalations --ack {e.get('module')}/{e.get('criterion_id')}"
173
+ )
174
+
175
+ # Notification dead-man's-switch — attempts without successes means the
176
+ # pipeline is broken and nobody is being reached.
177
+ from pcp.commands.watch import check_notify_heartbeat
178
+ hb = check_notify_heartbeat(pcp_dir)
179
+ if hb:
180
+ console.print(f"[red bold]{hb}[/red bold]")
@@ -0,0 +1,55 @@
1
+ """pcp takeover — one-shot: preflight -> kickoff -> autonomous build.
2
+
3
+ The single entrypoint for "point pcp at a vision doc and let it run the
4
+ project." Chains the existing doctor/kickoff/build commands rather than
5
+ duplicating their logic.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import yaml
13
+ from rich.console import Console
14
+
15
+ from pcp.commands.build import build
16
+ from pcp.commands.doctor import check_environment
17
+ from pcp.commands.kickoff import kickoff
18
+
19
+ console = Console()
20
+
21
+
22
+ def _current_phase(pcp_dir: Path) -> str | None:
23
+ path = pcp_dir / "SDLC_phase.yaml"
24
+ if not path.exists():
25
+ return None
26
+ return (yaml.safe_load(path.read_text()) or {}).get("current_phase")
27
+
28
+
29
+ @click.command()
30
+ @click.argument("vision_file", type=click.Path(exists=True))
31
+ @click.option("--path", "project_path", type=click.Path(), default=".",
32
+ help="Project root (default: current directory).")
33
+ @click.option("--force", is_flag=True, help="Force overwrite existing .pcp/ directory.")
34
+ def takeover(vision_file: str, project_path: str, force: bool):
35
+ """Take over a project end-to-end: preflight, kickoff from a vision doc, then build every pending criterion."""
36
+ root = Path(project_path).resolve()
37
+ pcp_dir = root / ".pcp"
38
+ ctx = click.get_current_context()
39
+
40
+ console.print("[bold]Step 1/3 — environment preflight[/bold]")
41
+ check_environment(pcp_dir, fatal_on_missing_required=True)
42
+
43
+ console.print("\n[bold]Step 2/3 — kickoff from vision[/bold]")
44
+ ctx.invoke(kickoff, vision_file=vision_file, project_path=project_path, force=force)
45
+
46
+ phase = _current_phase(pcp_dir)
47
+ if phase in (None, "planning"):
48
+ console.print(
49
+ "\n[yellow]Strategy not approved — stopping before build. "
50
+ "Re-run `pcp takeover` once you're ready.[/yellow]"
51
+ )
52
+ sys.exit(0)
53
+
54
+ console.print("\n[bold]Step 3/3 — autonomous build[/bold]")
55
+ ctx.invoke(build, module_name=None, project_path=project_path)
@@ -0,0 +1,167 @@
1
+ """pcp telemetry — summarize .pcp/telemetry.jsonl for analysis.
2
+
3
+ Closes the loop on "captured to analyse and learn": telemetry.jsonl has the raw
4
+ per-attempt/per-qa-check records, this command rolls them up so the data is
5
+ actually useful without hand-writing a notebook every time.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
17
+ from pcp import telemetry as telemetry_lib
18
+
19
+ console = Console()
20
+
21
+
22
+ @click.command(name="telemetry")
23
+ @click.option("--path", "project_path", type=click.Path(), default=None,
24
+ help="Project root (default: cwd, walks up to find .pcp/).")
25
+ @click.option("--json", "output_json", is_flag=True, help="Print raw per-module aggregates as JSON.")
26
+ def telemetry_cmd(project_path: str | None, output_json: bool):
27
+ """Summarize .pcp/telemetry.jsonl — per-module retries, QA error rate, languages, cost."""
28
+ try:
29
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
30
+ except NoPCPDir as e:
31
+ console.print(f"[red]Error:[/red] {e}")
32
+ sys.exit(2)
33
+
34
+ records = telemetry_lib.load(pcp_dir)
35
+ if not records:
36
+ console.print("[dim]No .pcp/telemetry.jsonl found — run `pcp build` first.[/dim]")
37
+ sys.exit(0)
38
+
39
+ agg = telemetry_lib.aggregate(records)
40
+ by_module = agg["by_module"]
41
+
42
+ if output_json:
43
+ click.echo(json.dumps({
44
+ k: {**v, "criteria": sorted(filter(None, v["criteria"])), "languages": sorted(v["languages"])}
45
+ for k, v in by_module.items()
46
+ }, indent=2))
47
+ return
48
+
49
+ table = Table(title="PCP Build Telemetry — per module")
50
+ table.add_column("Module")
51
+ table.add_column("Criteria")
52
+ table.add_column("Attempts")
53
+ table.add_column("Avg attempts/criterion")
54
+ table.add_column("QA error rate")
55
+ table.add_column("Tokens in/cache/out")
56
+ table.add_column("Cost")
57
+ table.add_column("Languages")
58
+
59
+ total_cost = 0.0
60
+ for m_name, v in sorted(by_module.items()):
61
+ n_crit = len(v["criteria"]) or 1
62
+ avg_attempts = v["attempts"] / n_crit
63
+ qa_rate = f"{v['qa_blocks']}/{v['qa_total']}" if v["qa_total"] else "—"
64
+ table.add_row(
65
+ m_name, str(len(v["criteria"])), str(v["attempts"]), f"{avg_attempts:.1f}",
66
+ qa_rate, f"{v['tokens_in']:,}/{v['tokens_cache_read']:,}/{v['tokens_out']:,}",
67
+ f"${v['cost']:.2f}", ", ".join(sorted(v["languages"])) or "—",
68
+ )
69
+ total_cost += v["cost"]
70
+
71
+ console.print(table)
72
+
73
+ # Worktree merge conflict rate — comparable against AgenticFlict's
74
+ # (arXiv:2604.03551) 27.67% agent-authored-PR conflict baseline.
75
+ merges = [r for r in records if r.get("check") == "worktree-merge"]
76
+ if merges:
77
+ conflicts = sum(1 for r in merges if r.get("result") == "block")
78
+ console.print(
79
+ f"[dim]Worktree merges: {len(merges)}, conflicts: {conflicts} "
80
+ f"({conflicts / len(merges):.0%}) — agentic-PR literature baseline 27.67% (AgenticFlict)[/dim]"
81
+ )
82
+
83
+ # Output per dollar over time. Nothing reported this, so a real ~6x
84
+ # degradation on Project O went unseen for a week while commits/day
85
+ # rose — the flattering metric was the only visible one.
86
+ weeks = telemetry_lib.productivity_by_week(records)
87
+ if len(weeks) > 1:
88
+ # Written vs landed, side by side. Telemetry alone reads healthy — on
89
+ # Project O it called 2026-W31 the most productive week of the run
90
+ # (+12,342 lines, $0.018/line) while git says non-test code grew by 599.
91
+ # The ratio between them is the signal; neither number is, alone.
92
+ repo = telemetry_lib.repo_net_lines_by_week(pcp_dir.parent)
93
+ landed = repo["by_week"]
94
+ wt = Table(title="Output per dollar — by week")
95
+ wt.add_column("Week")
96
+ wt.add_column("Attempts", justify="right")
97
+ wt.add_column("Spend", justify="right")
98
+ wt.add_column("Lines written", justify="right")
99
+ if landed:
100
+ wt.add_column("Landed (non-test)", justify="right")
101
+ wt.add_column("Survived", justify="right")
102
+ wt.add_column("$/landed line", justify="right")
103
+ else:
104
+ wt.add_column("$/written line", justify="right")
105
+
106
+ for w in weeks:
107
+ row = [w["week"], str(w["attempts"]), f"${w['cost_usd']:.2f}",
108
+ f"{w['net_lines']:+,}"]
109
+ if landed:
110
+ net = landed.get(w["week"])
111
+ if net is None:
112
+ row += ["—", "—", "—"]
113
+ else:
114
+ row.append(f"{net:+,}")
115
+ written = w["net_lines"]
116
+ if net > written:
117
+ # More landed than the build loop recorded writing, so code
118
+ # arrived by some other path — an ad-hoc agent, a hand edit,
119
+ # a bulk merge. That is CTRL-037's story showing up in the
120
+ # numbers, and printing it as "5185% survived" would bury it.
121
+ row.append("[yellow]outside loop[/yellow]")
122
+ elif written > 0:
123
+ row.append(f"{net / written:.0%}")
124
+ else:
125
+ row.append("—")
126
+ # A week that spent money and landed nothing is the single most
127
+ # important row to see — never render it as a tidy $0.00.
128
+ row.append(f"${w['cost_usd'] / net:.2f}" if net > 0
129
+ else "[red]nothing landed[/red]")
130
+ else:
131
+ per = w["usd_per_net_line"]
132
+ row.append(f"${per:.3f}" if per is not None
133
+ else ("[red]no net output[/red]" if w["cost_usd"] > 0 else "—"))
134
+ wt.add_row(*row)
135
+ console.print(wt)
136
+
137
+ if landed:
138
+ console.print(
139
+ "[dim]`Lines written` is every attempt's diff, so it includes superseded "
140
+ "attempts, reverts and test code. `Landed` is net authored non-test source "
141
+ "that survived in git. `Survived` is the ratio — the number actually worth "
142
+ "watching, since a high written count with a low survival rate is churn, "
143
+ "not velocity. `outside loop` means more landed than the build loop recorded "
144
+ "writing, i.e. code arrived by some other path.[/dim]"
145
+ )
146
+ # No silent caps: say what was excluded and why.
147
+ skipped = repo["bulk_commits_skipped"]
148
+ if skipped:
149
+ detail = ", ".join(f"{w} ({n})" for w, n in sorted(skipped.items()))
150
+ console.print(
151
+ f"[dim]Excluded {sum(skipped.values())} bulk commit(s) over "
152
+ f"{repo['bulk_threshold']:,} changed source lines — {detail}. Those are "
153
+ "vendor imports, bulk moves or generated dumps, not one criterion's "
154
+ "work; counting them made survival read 107372% on a real project.[/dim]"
155
+ )
156
+ else:
157
+ console.print(
158
+ "[dim]git unavailable — showing written lines only. These count every "
159
+ "attempt's diff including superseded work, so treat $/written line as a "
160
+ "trend, not an accounting figure.[/dim]"
161
+ )
162
+
163
+ console.print(
164
+ f"\n[dim]{len(records)} total records — {len(agg['build_records'])} build, "
165
+ f"{len(agg['qa_records'])} qa — total cost ~${total_cost:.2f}[/dim]"
166
+ )
167
+ console.print("[dim]Raw data: .pcp/telemetry.jsonl (one JSON object per line — load into pandas/duckdb for deeper analysis)[/dim]")
@@ -0,0 +1,153 @@
1
+ """pcp validate-module <name> — per-module spec alignment check."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import yaml
9
+ from rich.console import Console
10
+
11
+ from pcp.pcp_dir import find_pcp_dir, get_modules_dir, get_objective, get_decomposition, NoPCPDir
12
+ from pcp.schema.validator import validate_file, load_yaml
13
+ from pcp.llm import client as llm
14
+
15
+ console = Console()
16
+
17
+ SYSTEM_PROMPT = """\
18
+ You are a program-context auditor. Check whether a single module specification \
19
+ is aligned with the program objective and decomposition rationale.
20
+
21
+ Output ONLY valid JSON — no prose, no markdown, no code fences.
22
+
23
+ Output schema:
24
+ {
25
+ "alignment_score": 0.0,
26
+ "aligned": true,
27
+ "gaps": ["string"],
28
+ "contradictions": ["string"],
29
+ "decomposition_conflicts": ["string"],
30
+ "suggestions": ["string"]
31
+ }
32
+
33
+ alignment_score: 0.0 (no alignment) to 1.0 (perfectly aligned).
34
+ aligned: true if alignment_score >= 0.7 and no contradictions.
35
+ """
36
+
37
+
38
+ def _build_prompt(objective: str, decomposition: str | None, module_name: str, spec: dict) -> str:
39
+ parts = [f"## Program Objective\n\n{objective}\n"]
40
+ if decomposition:
41
+ parts.append(f"## Decomposition Rationale\n\n{decomposition}\n")
42
+ parts.append(f"## Module to Validate: {module_name}\n")
43
+ parts.append(f"```yaml\n{yaml.dump(spec, default_flow_style=False)}```\n")
44
+ parts.append(
45
+ f"Does the '{module_name}' module spec align with the objective and decomposition? "
46
+ "Are its objective_coverage claims accurate? Does it conflict with the decomposition?"
47
+ )
48
+ return "\n".join(parts)
49
+
50
+
51
+ def run_validate_module(pcp_dir: Path, module_name: str) -> dict | None:
52
+ """Core module-vs-decomposition alignment check, reusable outside the CLI
53
+ command (e.g. build.py's wave-merge gate). Returns the LLM judge's result
54
+ dict, or None if the module is missing/deprecated/objective.md absent --
55
+ callers decide what "no result" means for their own control flow, same
56
+ posture as run_validate_strategy."""
57
+ modules_dir = get_modules_dir(pcp_dir)
58
+ spec_path = modules_dir / module_name / "spec.yaml"
59
+ if not spec_path.exists():
60
+ return None
61
+
62
+ validate_file(spec_path, "module_spec") # schema errors surfaced by the CLI wrapper, not fatal here
63
+
64
+ spec = load_yaml(spec_path)
65
+ if spec.get("deprecated"):
66
+ return None
67
+
68
+ objective_path = get_objective(pcp_dir)
69
+ if not objective_path.exists():
70
+ return None
71
+
72
+ objective = objective_path.read_text()
73
+ decomp_path = get_decomposition(pcp_dir)
74
+ decomposition = decomp_path.read_text() if decomp_path.exists() else None
75
+
76
+ return llm.call_json(
77
+ SYSTEM_PROMPT, _build_prompt(objective, decomposition, module_name, spec),
78
+ model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="validate-module",
79
+ )
80
+
81
+
82
+ @click.command()
83
+ @click.argument("module_name")
84
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
85
+ @click.option("--path", "project_path", type=click.Path(), default=None)
86
+ def validate_module(module_name: str, output_json: bool, project_path: str | None):
87
+ """Check whether a module spec aligns with the objective and decomposition."""
88
+ try:
89
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
90
+ except NoPCPDir as e:
91
+ console.print(f"[red]Error:[/red] {e}")
92
+ sys.exit(2)
93
+
94
+ modules_dir = get_modules_dir(pcp_dir)
95
+ spec_path = modules_dir / module_name / "spec.yaml"
96
+
97
+ if not spec_path.exists():
98
+ console.print(f"[red]Module '{module_name}' not found:[/red] {spec_path}")
99
+ console.print(f"Available: {', '.join(p.parent.name for p in modules_dir.glob('*/spec.yaml'))}")
100
+ sys.exit(2)
101
+
102
+ errors = validate_file(spec_path, "module_spec")
103
+ if errors:
104
+ console.print(f"[yellow]⚠ schema errors in {module_name}/spec.yaml:[/yellow]")
105
+ for e in errors:
106
+ console.print(f" {e}")
107
+
108
+ spec = load_yaml(spec_path)
109
+ if spec.get("deprecated"):
110
+ console.print(f"[dim]{module_name} is deprecated — skipping.[/dim]")
111
+ sys.exit(0)
112
+
113
+ objective_path = get_objective(pcp_dir)
114
+ if not objective_path.exists():
115
+ console.print("[red]Error:[/red] .pcp/objective.md not found.")
116
+ sys.exit(2)
117
+
118
+ if not output_json:
119
+ console.print(f"[dim]Validating module '{module_name}'...[/dim]")
120
+
121
+ try:
122
+ result = run_validate_module(pcp_dir, module_name)
123
+ except RuntimeError as e:
124
+ console.print(f"[red]Error:[/red] {e}")
125
+ sys.exit(2)
126
+ except ValueError as e:
127
+ console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
128
+ sys.exit(2)
129
+
130
+ if output_json:
131
+ click.echo(json.dumps(result, indent=2))
132
+ sys.exit(0 if result.get("aligned") else 1)
133
+
134
+ score = result.get("alignment_score", 0.0)
135
+ aligned = result.get("aligned", False)
136
+ color = "green" if aligned else "red"
137
+
138
+ console.print(f"\n[bold]Module:[/bold] {module_name}")
139
+ console.print(f"[bold]Alignment:[/bold] [{color}]{score:.0%}[/{color}] {'✓' if aligned else '✗'}\n")
140
+
141
+ for gap in result.get("gaps", []):
142
+ console.print(f" [yellow]⚠ Gap:[/yellow] {gap}")
143
+ for c in result.get("contradictions", []):
144
+ console.print(f" [red]✗ Contradiction:[/red] {c}")
145
+ for dc in result.get("decomposition_conflicts", []):
146
+ console.print(f" [red]✗ Decomp conflict:[/red] {dc}")
147
+ for s in result.get("suggestions", []):
148
+ console.print(f" [dim]→ {s}[/dim]")
149
+
150
+ if aligned and not result.get("gaps") and not result.get("contradictions"):
151
+ console.print("[green]✓ Module spec is aligned.[/green]")
152
+
153
+ sys.exit(0 if aligned else 1)