program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/commands/amend.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""pcp amend — the human-authorized write path for every protected `.pcp/`
|
|
2
|
+
file that previously had none.
|
|
3
|
+
|
|
4
|
+
Before this, of the 10 paths in ci_rules.yaml's `protected_path` scope only 4
|
|
5
|
+
had a propose/approve/write command: objective.md + target_state.md via
|
|
6
|
+
`pcp correct-objective`, and modules/*/spec.yaml + acceptance.yaml via
|
|
7
|
+
`pcp kickoff`/`pcp pm`. The other six -- architecture.md, decomposition.md,
|
|
8
|
+
dependency_map.md, ci_rules.yaml, controls.yaml, SDLC_phase.yaml -- were
|
|
9
|
+
written once at kickoff (or, for dependency_map.md, only by `pcp import`) and
|
|
10
|
+
had no path afterwards. Combined with doctrine wording that told agents never
|
|
11
|
+
to edit a protected file, the practical outcome was that a detailed feature
|
|
12
|
+
discussion ending in "now update the specs" updated nothing at all.
|
|
13
|
+
|
|
14
|
+
`amend` closes that. Same mechanic as `correct-objective` (see spec_write.py):
|
|
15
|
+
LLM proposes from stated intent, human reviews a real unified diff, approves,
|
|
16
|
+
then it's written. The three governance files additionally get schema
|
|
17
|
+
validation and a deterministic weakening check before the human is even asked.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
import yaml
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
from pcp import spec_write
|
|
28
|
+
from pcp.llm import client as llm
|
|
29
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
30
|
+
from pcp.spec_write import SpecTarget
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
# Files whose amendment changes what modules exist / what they cover, and so
|
|
35
|
+
# must be re-checked against the objective immediately afterwards.
|
|
36
|
+
_REVALIDATES = {"decomposition", "dependency_map"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _targets(pcp_dir: Path) -> dict[str, SpecTarget]:
|
|
40
|
+
return {
|
|
41
|
+
"architecture": SpecTarget(
|
|
42
|
+
name="architecture.md",
|
|
43
|
+
path=pcp_dir / "architecture.md",
|
|
44
|
+
key="content",
|
|
45
|
+
description="program-level tech decisions and constraints",
|
|
46
|
+
),
|
|
47
|
+
"decomposition": SpecTarget(
|
|
48
|
+
name="strategy/decomposition.md",
|
|
49
|
+
path=pcp_dir / "strategy" / "decomposition.md",
|
|
50
|
+
key="content",
|
|
51
|
+
description="how the objective breaks into modules, and why",
|
|
52
|
+
),
|
|
53
|
+
"dependency_map": SpecTarget(
|
|
54
|
+
name="strategy/dependency_map.md",
|
|
55
|
+
path=pcp_dir / "strategy" / "dependency_map.md",
|
|
56
|
+
key="content",
|
|
57
|
+
description="module build order and inter-module contracts",
|
|
58
|
+
),
|
|
59
|
+
"ci_rules": SpecTarget(
|
|
60
|
+
name="ci_rules.yaml",
|
|
61
|
+
path=pcp_dir / "ci_rules.yaml",
|
|
62
|
+
key="content",
|
|
63
|
+
schema="ci_rules",
|
|
64
|
+
guarded=True,
|
|
65
|
+
description="the program's enforced laws (Layer 1 gates)",
|
|
66
|
+
),
|
|
67
|
+
"controls": SpecTarget(
|
|
68
|
+
name="controls.yaml",
|
|
69
|
+
path=pcp_dir / "controls.yaml",
|
|
70
|
+
key="content",
|
|
71
|
+
schema="controls",
|
|
72
|
+
guarded=True,
|
|
73
|
+
description="the control catalog, cross-referenced to SSDF practices",
|
|
74
|
+
),
|
|
75
|
+
"sdlc_phase": SpecTarget(
|
|
76
|
+
name="SDLC_phase.yaml",
|
|
77
|
+
path=pcp_dir / "SDLC_phase.yaml",
|
|
78
|
+
key="content",
|
|
79
|
+
schema="sdlc_phase",
|
|
80
|
+
guarded=True,
|
|
81
|
+
description="current SDLC phase and its machine-enforced exit criteria",
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Accepted aliases, so `pcp amend architecture.md` and `pcp amend
|
|
86
|
+
# strategy/decomposition.md` work as well as the short keys.
|
|
87
|
+
_ALIASES = {
|
|
88
|
+
"architecture.md": "architecture",
|
|
89
|
+
"decomposition.md": "decomposition",
|
|
90
|
+
"strategy/decomposition.md": "decomposition",
|
|
91
|
+
"dependency-map": "dependency_map",
|
|
92
|
+
"dependency_map.md": "dependency_map",
|
|
93
|
+
"strategy/dependency_map.md": "dependency_map",
|
|
94
|
+
"ci-rules": "ci_rules",
|
|
95
|
+
"ci_rules.yaml": "ci_rules",
|
|
96
|
+
"controls.yaml": "controls",
|
|
97
|
+
"sdlc": "sdlc_phase",
|
|
98
|
+
"sdlc-phase": "sdlc_phase",
|
|
99
|
+
"sdlc_phase.yaml": "sdlc_phase",
|
|
100
|
+
"SDLC_phase.yaml": "sdlc_phase",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_target_key(raw: str) -> str | None:
|
|
105
|
+
"""Map a user-typed file argument onto a registry key. Deterministic
|
|
106
|
+
lookup, never fuzzy — an LLM guessing which protected file the human meant
|
|
107
|
+
is exactly the drift this command exists to prevent."""
|
|
108
|
+
if raw in _ALIASES:
|
|
109
|
+
return _ALIASES[raw]
|
|
110
|
+
# Order matters: strip "./" before ".pcp/", and never lstrip(chars) here --
|
|
111
|
+
# lstrip("./") eats the leading dot of ".pcp/" itself.
|
|
112
|
+
normalised = raw.strip().removeprefix("./").removeprefix(".pcp/").removeprefix("/")
|
|
113
|
+
if normalised in _ALIASES:
|
|
114
|
+
return _ALIASES[normalised]
|
|
115
|
+
key = normalised.replace("-", "_")
|
|
116
|
+
return key if key in {
|
|
117
|
+
"architecture", "decomposition", "dependency_map",
|
|
118
|
+
"ci_rules", "controls", "sdlc_phase",
|
|
119
|
+
} else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_MARKDOWN_RULES = """\
|
|
123
|
+
- Keep everything NOT affected by the change exactly as-is -- do not rephrase, \
|
|
124
|
+
reorder, or "improve" unrelated sections.
|
|
125
|
+
- Change only what the stated change actually requires.
|
|
126
|
+
- Do not invent scope beyond what the change states.
|
|
127
|
+
- Preserve the file's existing structure and heading style."""
|
|
128
|
+
|
|
129
|
+
_YAML_RULES = """\
|
|
130
|
+
- Keep everything NOT affected by the change exactly as-is, including comments, \
|
|
131
|
+
key order, and formatting.
|
|
132
|
+
- Change only what the stated change actually requires.
|
|
133
|
+
- NEVER remove an existing rule/control/exit-criterion, and never downgrade a \
|
|
134
|
+
hard_block severity, unless the stated change explicitly and unambiguously asks \
|
|
135
|
+
for that removal or downgrade.
|
|
136
|
+
- Preserve every existing id verbatim -- ids are written into telemetry.jsonl by \
|
|
137
|
+
gate call sites and renaming one silently breaks the audit trail."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_system_prompt(target: SpecTarget) -> str:
|
|
141
|
+
is_yaml = target.name.endswith(".yaml")
|
|
142
|
+
rules = _YAML_RULES if is_yaml else _MARKDOWN_RULES
|
|
143
|
+
return (
|
|
144
|
+
f"You are the program's spec author. A human has decided, during "
|
|
145
|
+
f"conversation, on a change that must now be reflected in "
|
|
146
|
+
f"`.pcp/{target.name}` ({target.description}). You are given the "
|
|
147
|
+
f"current file in full, plus read-only context.\n\n"
|
|
148
|
+
f"Rewrite the file to incorporate the change faithfully:\n{rules}\n\n"
|
|
149
|
+
f"You must output ONLY valid JSON — no prose, no markdown, no code "
|
|
150
|
+
f"fences.\n\nOutput schema:\n{spec_write.build_output_schema([target])}\n"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_user_prompt(pcp_dir: Path, target: SpecTarget, change: str) -> str:
|
|
155
|
+
sections = [f"## Requested change\n{change}"]
|
|
156
|
+
|
|
157
|
+
current = target.path.read_text() if target.path.exists() else "(file does not exist yet)"
|
|
158
|
+
sections.append(f"## Current {target.name}\n{current}")
|
|
159
|
+
|
|
160
|
+
# Read-only context — never rewritten by this command, only used so the
|
|
161
|
+
# proposal stays consistent with what the program already says.
|
|
162
|
+
objective = pcp_dir / "objective.md"
|
|
163
|
+
if objective.exists():
|
|
164
|
+
sections.append(f"## Read-only context: objective.md\n{objective.read_text()}")
|
|
165
|
+
|
|
166
|
+
decomposition = pcp_dir / "strategy" / "decomposition.md"
|
|
167
|
+
if target.name != "strategy/decomposition.md" and decomposition.exists():
|
|
168
|
+
sections.append(f"## Read-only context: decomposition.md\n{decomposition.read_text()}")
|
|
169
|
+
|
|
170
|
+
modules_dir = pcp_dir / "strategy" / "modules"
|
|
171
|
+
if modules_dir.exists():
|
|
172
|
+
summaries = []
|
|
173
|
+
for spec_path in sorted(modules_dir.glob("*/spec.yaml")):
|
|
174
|
+
try:
|
|
175
|
+
spec = yaml.safe_load(spec_path.read_text()) or {}
|
|
176
|
+
except yaml.YAMLError:
|
|
177
|
+
continue
|
|
178
|
+
name = spec.get("module") or spec_path.parent.name
|
|
179
|
+
purpose = str(spec.get("purpose", "")).strip().splitlines()[:1]
|
|
180
|
+
depends = spec.get("depends_on") or spec.get("dependencies") or []
|
|
181
|
+
summaries.append(
|
|
182
|
+
f"- {name}: {purpose[0] if purpose else ''} (depends_on: {depends or 'none'})"
|
|
183
|
+
)
|
|
184
|
+
if summaries:
|
|
185
|
+
sections.append("## Read-only context: existing modules\n" + "\n".join(summaries))
|
|
186
|
+
|
|
187
|
+
return "\n\n".join(sections)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _revalidate(pcp_dir: Path) -> None:
|
|
191
|
+
"""Re-run validate-strategy after a change to what modules exist/cover —
|
|
192
|
+
same posture as correct-objective's. Advisory: a failure here never undoes
|
|
193
|
+
the write the human already approved."""
|
|
194
|
+
from pcp.commands.validate_strategy import (
|
|
195
|
+
_build_user_prompt as build_val_prompt,
|
|
196
|
+
SYSTEM_PROMPT as VAL_SYSTEM_PROMPT,
|
|
197
|
+
_render_results as render_val_results,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
console.print("\n[bold]Running validate-strategy against the amended strategy...[/bold]")
|
|
201
|
+
obj_path = pcp_dir / "objective.md"
|
|
202
|
+
objective = obj_path.read_text() if obj_path.exists() else ""
|
|
203
|
+
dec_path = pcp_dir / "strategy" / "decomposition.md"
|
|
204
|
+
decomposition = dec_path.read_text() if dec_path.exists() else ""
|
|
205
|
+
all_specs = {}
|
|
206
|
+
for spec_path in sorted((pcp_dir / "strategy" / "modules").glob("*/spec.yaml")):
|
|
207
|
+
try:
|
|
208
|
+
all_specs[spec_path.parent.name] = yaml.safe_load(spec_path.read_text()) or {}
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
try:
|
|
212
|
+
val_prompt = build_val_prompt(objective, decomposition, all_specs)
|
|
213
|
+
val_result = llm.call_json(
|
|
214
|
+
VAL_SYSTEM_PROMPT, val_prompt, model=llm.JUDGE_MODEL,
|
|
215
|
+
pcp_dir=pcp_dir, command="amend-validate",
|
|
216
|
+
)
|
|
217
|
+
render_val_results(pcp_dir, val_result, output_json=False)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
console.print(f"[yellow]Warning: could not run validate-strategy automatically: {e}[/yellow]")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@click.command("amend")
|
|
223
|
+
@click.argument("target_file")
|
|
224
|
+
@click.argument("change")
|
|
225
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
226
|
+
@click.option("--yes", "yes", is_flag=True, help="Skip the interactive diff-approval prompt (scripted/CI use).")
|
|
227
|
+
@click.option("--allow-weakening", "allow_weakening", is_flag=True,
|
|
228
|
+
help="Permit a governance-file rewrite that removes a rule/control/exit-criterion or downgrades a hard_block. Recorded in decision_log.jsonl.")
|
|
229
|
+
def amend(target_file: str, change: str, project_path: str | None, yes: bool, allow_weakening: bool):
|
|
230
|
+
"""Propose + human-approve a rewrite of a human-authorized .pcp/ file.
|
|
231
|
+
|
|
232
|
+
TARGET_FILE is one of: architecture, decomposition, dependency_map,
|
|
233
|
+
ci_rules, controls, sdlc_phase (file names like `architecture.md` also work).
|
|
234
|
+
|
|
235
|
+
For objective.md/target_state.md use `pcp correct-objective`; for module
|
|
236
|
+
spec.yaml/acceptance.yaml use `pcp pm`.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
240
|
+
except NoPCPDir as e:
|
|
241
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
242
|
+
sys.exit(2)
|
|
243
|
+
|
|
244
|
+
key = resolve_target_key(target_file)
|
|
245
|
+
if key is None:
|
|
246
|
+
if target_file.strip().removeprefix("./").removeprefix(".pcp/") in (
|
|
247
|
+
"objective.md", "target_state.md", "objective", "target_state"
|
|
248
|
+
):
|
|
249
|
+
console.print("[red]Error:[/red] use `pcp correct-objective \"<correction>\"` for objective.md/target_state.md.")
|
|
250
|
+
sys.exit(2)
|
|
251
|
+
if "spec.yaml" in target_file or "acceptance.yaml" in target_file:
|
|
252
|
+
console.print("[red]Error:[/red] use `pcp pm \"<intent>\"` for module spec.yaml/acceptance.yaml.")
|
|
253
|
+
sys.exit(2)
|
|
254
|
+
console.print(
|
|
255
|
+
f"[red]Error:[/red] {target_file} is not an amendable file. Choose one of: "
|
|
256
|
+
"architecture, decomposition, dependency_map, ci_rules, controls, sdlc_phase."
|
|
257
|
+
)
|
|
258
|
+
sys.exit(2)
|
|
259
|
+
|
|
260
|
+
target = _targets(pcp_dir)[key]
|
|
261
|
+
if not change.strip():
|
|
262
|
+
console.print("[red]Error:[/red] pass the change to make as the second argument.")
|
|
263
|
+
sys.exit(2)
|
|
264
|
+
|
|
265
|
+
outcome = spec_write.propose_and_write(
|
|
266
|
+
pcp_dir,
|
|
267
|
+
[target],
|
|
268
|
+
build_system_prompt(target),
|
|
269
|
+
build_user_prompt(pcp_dir, target, change),
|
|
270
|
+
command="amend",
|
|
271
|
+
intent=change,
|
|
272
|
+
yes=yes,
|
|
273
|
+
allow_weakening=allow_weakening,
|
|
274
|
+
no_change_hint="If this is unexpected, restate the change more concretely.",
|
|
275
|
+
)
|
|
276
|
+
if not outcome.written:
|
|
277
|
+
sys.exit(0)
|
|
278
|
+
|
|
279
|
+
if key in _REVALIDATES:
|
|
280
|
+
_revalidate(pcp_dir)
|
|
281
|
+
elif target.guarded:
|
|
282
|
+
console.print("\n[dim]Gate definitions changed — run `pcp check` before your next commit "
|
|
283
|
+
"so Layer 1 runs against the amended rules.[/dim]")
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""pcp architect-review — architecture principle review against persona + KB."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
13
|
+
from pcp.llm import client as llm
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """\
|
|
18
|
+
You are a senior software architect reviewing code changes.
|
|
19
|
+
You have a project-specific persona, architecture constraints, and a curated knowledge base.
|
|
20
|
+
Review the provided diff (or module spec) against the persona rules and KB.
|
|
21
|
+
|
|
22
|
+
Output ONLY valid JSON — no prose, no markdown, no code fences.
|
|
23
|
+
|
|
24
|
+
Output schema:
|
|
25
|
+
{
|
|
26
|
+
"findings": [
|
|
27
|
+
{
|
|
28
|
+
"severity": "BLOCK | WARN | NOTE",
|
|
29
|
+
"location": "file:line or module name or 'general'",
|
|
30
|
+
"principle": "which architecture principle or persona rule was violated",
|
|
31
|
+
"finding": "what is wrong",
|
|
32
|
+
"fix": "concrete suggestion to fix it"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"summary": "one sentence overall assessment",
|
|
36
|
+
"blocks": 0,
|
|
37
|
+
"warns": 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
severity:
|
|
41
|
+
BLOCK = must fix before merge (violates hard architecture constraint)
|
|
42
|
+
WARN = fix before ship (design smell, weak boundary, missing invariant)
|
|
43
|
+
NOTE = track, non-blocking (observation for future consideration)
|
|
44
|
+
|
|
45
|
+
If no findings, return {"findings": [], "summary": "No architecture violations found.", "blocks": 0, "warns": 0}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_diff(base: str) -> str:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", "diff", f"{base}...HEAD"],
|
|
52
|
+
capture_output=True, text=True,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
raise RuntimeError(f"git diff failed: {result.stderr.strip()}")
|
|
56
|
+
return result.stdout[:14000]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_staged_diff() -> str:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["git", "diff", "--cached"],
|
|
62
|
+
capture_output=True, text=True,
|
|
63
|
+
)
|
|
64
|
+
return result.stdout[:14000]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_persona(pcp_dir: Path) -> str:
|
|
68
|
+
persona_path = pcp_dir / "architect_persona.md"
|
|
69
|
+
if not persona_path.exists():
|
|
70
|
+
return ""
|
|
71
|
+
return persona_path.read_text()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_kb(pcp_dir: Path, changed_files: list[str]) -> str:
|
|
75
|
+
"""Load ADRs always. Load domain KB contextually based on changed files."""
|
|
76
|
+
kb_dir = pcp_dir / "kb"
|
|
77
|
+
if not kb_dir.exists():
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
parts = []
|
|
81
|
+
|
|
82
|
+
# ADRs — always load (small, high signal)
|
|
83
|
+
adr_dir = kb_dir / "adr"
|
|
84
|
+
if adr_dir.exists():
|
|
85
|
+
adr_files = sorted(adr_dir.glob("*.md"))
|
|
86
|
+
if adr_files:
|
|
87
|
+
parts.append("## Architecture Decision Records\n")
|
|
88
|
+
for f in adr_files:
|
|
89
|
+
parts.append(f"### {f.stem}\n{f.read_text()}\n")
|
|
90
|
+
|
|
91
|
+
# Domain KB — contextual
|
|
92
|
+
domain_dir = kb_dir / "domain"
|
|
93
|
+
if domain_dir.exists():
|
|
94
|
+
for domain_file in sorted(domain_dir.glob("*.md")):
|
|
95
|
+
tag = domain_file.stem.lower()
|
|
96
|
+
# Load if any changed file path contains the tag word
|
|
97
|
+
if any(tag in cf.lower() for cf in changed_files) or not changed_files:
|
|
98
|
+
parts.append(f"## Domain KB: {domain_file.stem}\n{domain_file.read_text()}\n")
|
|
99
|
+
|
|
100
|
+
return "\n".join(parts)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _changed_files_from_diff(diff: str) -> list[str]:
|
|
104
|
+
files = []
|
|
105
|
+
for line in diff.splitlines():
|
|
106
|
+
if line.startswith("+++ b/"):
|
|
107
|
+
files.append(line[6:])
|
|
108
|
+
return files
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_module_spec(pcp_dir: Path, module_name: str) -> str:
|
|
112
|
+
mod_dir = pcp_dir / "strategy" / "modules" / module_name
|
|
113
|
+
parts = []
|
|
114
|
+
spec = mod_dir / "spec.yaml"
|
|
115
|
+
acceptance = mod_dir / "acceptance.yaml"
|
|
116
|
+
if spec.exists():
|
|
117
|
+
parts.append(f"## Module Spec ({module_name})\n```yaml\n{spec.read_text()}\n```")
|
|
118
|
+
if acceptance.exists():
|
|
119
|
+
parts.append(f"## Acceptance Criteria\n```yaml\n{acceptance.read_text()}\n```")
|
|
120
|
+
return "\n\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_prompt(
|
|
124
|
+
persona: str,
|
|
125
|
+
architecture: str,
|
|
126
|
+
kb: str,
|
|
127
|
+
diff_or_spec: str,
|
|
128
|
+
mode: str,
|
|
129
|
+
) -> str:
|
|
130
|
+
parts = []
|
|
131
|
+
|
|
132
|
+
if persona:
|
|
133
|
+
parts.append(f"## Architect Persona\n\n{persona}")
|
|
134
|
+
|
|
135
|
+
if architecture:
|
|
136
|
+
parts.append(f"## Project Architecture Constraints\n\n{architecture}")
|
|
137
|
+
|
|
138
|
+
if kb:
|
|
139
|
+
parts.append(f"## Knowledge Base\n\n{kb}")
|
|
140
|
+
|
|
141
|
+
if mode == "diff":
|
|
142
|
+
parts.append(f"## Code Diff to Review\n\n```diff\n{diff_or_spec}\n```")
|
|
143
|
+
parts.append("Review this diff against your persona rules, architecture constraints, and KB.")
|
|
144
|
+
else:
|
|
145
|
+
parts.append(f"## Module Spec to Review\n\n{diff_or_spec}")
|
|
146
|
+
parts.append("Review this module spec for architecture principle violations.")
|
|
147
|
+
|
|
148
|
+
return "\n\n".join(parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _render_table(result: dict) -> None:
|
|
152
|
+
findings = result.get("findings", [])
|
|
153
|
+
blocks = result.get("blocks", 0)
|
|
154
|
+
warns = result.get("warns", 0)
|
|
155
|
+
|
|
156
|
+
summary = result.get("summary", "")
|
|
157
|
+
color = "green" if blocks == 0 and warns == 0 else ("red" if blocks > 0 else "yellow")
|
|
158
|
+
console.print(f"\n[bold]Architecture Review[/bold] [{color}]{blocks} BLOCK {warns} WARN[/{color}]")
|
|
159
|
+
console.print(f"[dim]{summary}[/dim]\n")
|
|
160
|
+
|
|
161
|
+
if not findings:
|
|
162
|
+
console.print(" [green]✓[/green] No architecture violations found.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
table = Table(show_header=True, header_style="bold")
|
|
166
|
+
table.add_column("Sev", width=6)
|
|
167
|
+
table.add_column("Location", width=28)
|
|
168
|
+
table.add_column("Principle", width=22)
|
|
169
|
+
table.add_column("Finding")
|
|
170
|
+
|
|
171
|
+
sev_colors = {"BLOCK": "red", "WARN": "yellow", "NOTE": "dim"}
|
|
172
|
+
|
|
173
|
+
for f in findings:
|
|
174
|
+
sev = f.get("severity", "NOTE")
|
|
175
|
+
color = sev_colors.get(sev, "dim")
|
|
176
|
+
table.add_row(
|
|
177
|
+
f"[{color}]{sev}[/{color}]",
|
|
178
|
+
f.get("location", ""),
|
|
179
|
+
f.get("principle", ""),
|
|
180
|
+
f"{f.get('finding', '')} → [italic]{f.get('fix', '')}[/italic]",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
console.print(table)
|
|
184
|
+
console.print("\n[dim]BLOCK = fix before merge. WARN = fix before ship. NOTE = track.[/dim]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@click.command()
|
|
188
|
+
@click.option("--base", default="main", show_default=True,
|
|
189
|
+
help="Base branch for diff (ignored if --staged or --module).")
|
|
190
|
+
@click.option("--staged", is_flag=True,
|
|
191
|
+
help="Review staged changes (pre-commit mode).")
|
|
192
|
+
@click.option("--module", "module_name", default=None,
|
|
193
|
+
help="Review a specific module spec instead of a diff.")
|
|
194
|
+
@click.option("--json", "output_json", is_flag=True,
|
|
195
|
+
help="Output raw JSON.")
|
|
196
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
197
|
+
help="Project root override.")
|
|
198
|
+
@click.option("--fail-on-block", is_flag=True,
|
|
199
|
+
help="Exit 1 if any BLOCK findings. For CI use.")
|
|
200
|
+
def architect_review(
|
|
201
|
+
base: str,
|
|
202
|
+
staged: bool,
|
|
203
|
+
module_name: str | None,
|
|
204
|
+
output_json: bool,
|
|
205
|
+
project_path: str | None,
|
|
206
|
+
fail_on_block: bool,
|
|
207
|
+
):
|
|
208
|
+
"""Architecture principle review against project persona + KB (advisory)."""
|
|
209
|
+
try:
|
|
210
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
211
|
+
except NoPCPDir as e:
|
|
212
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
213
|
+
sys.exit(2)
|
|
214
|
+
|
|
215
|
+
persona = _load_persona(pcp_dir)
|
|
216
|
+
architecture = (pcp_dir / "architecture.md").read_text() if (pcp_dir / "architecture.md").exists() else ""
|
|
217
|
+
|
|
218
|
+
if module_name:
|
|
219
|
+
spec_text = _load_module_spec(pcp_dir, module_name)
|
|
220
|
+
if not spec_text:
|
|
221
|
+
console.print(f"[red]Error:[/red] No spec found for module '{module_name}'")
|
|
222
|
+
sys.exit(2)
|
|
223
|
+
kb = _load_kb(pcp_dir, [module_name])
|
|
224
|
+
mode = "module"
|
|
225
|
+
target = spec_text
|
|
226
|
+
label = f"module:{module_name}"
|
|
227
|
+
else:
|
|
228
|
+
if staged:
|
|
229
|
+
diff = _get_staged_diff()
|
|
230
|
+
label = "staged changes"
|
|
231
|
+
else:
|
|
232
|
+
diff = _get_diff(base)
|
|
233
|
+
label = f"diff vs {base}"
|
|
234
|
+
|
|
235
|
+
if not diff.strip():
|
|
236
|
+
console.print(f"[dim]No changes to review ({label}).[/dim]")
|
|
237
|
+
sys.exit(0)
|
|
238
|
+
|
|
239
|
+
changed_files = _changed_files_from_diff(diff)
|
|
240
|
+
kb = _load_kb(pcp_dir, changed_files)
|
|
241
|
+
mode = "diff"
|
|
242
|
+
target = diff
|
|
243
|
+
|
|
244
|
+
if not persona and not architecture:
|
|
245
|
+
console.print(
|
|
246
|
+
"[yellow]Warning:[/yellow] No architect_persona.md and no architecture.md found. "
|
|
247
|
+
"Review will be generic. Run [cyan]pcp init[/cyan] to scaffold persona."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if not output_json:
|
|
251
|
+
console.print(f"[dim]Reviewing {label}...[/dim]")
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
result = llm.call_json(
|
|
255
|
+
SYSTEM_PROMPT,
|
|
256
|
+
_build_prompt(persona, architecture, kb, target, mode),
|
|
257
|
+
model=llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="architect-review",
|
|
258
|
+
)
|
|
259
|
+
except RuntimeError as e:
|
|
260
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
261
|
+
sys.exit(2)
|
|
262
|
+
except ValueError as e:
|
|
263
|
+
console.print(f"[red]LLM returned invalid JSON:[/red] {e}")
|
|
264
|
+
sys.exit(2)
|
|
265
|
+
|
|
266
|
+
# Same gap class as `pcp verify`: this command is called directly by a
|
|
267
|
+
# harness-driven agent (pcp build-plan + the Workflow tool's agent()), not
|
|
268
|
+
# only through build.py's own _run_architect_review wrapper -- which was
|
|
269
|
+
# the only place this gate's telemetry got recorded before. Not a removed
|
|
270
|
+
# hook, an omission: it was only ever wired for the older path.
|
|
271
|
+
from pcp import telemetry
|
|
272
|
+
telemetry.record(
|
|
273
|
+
pcp_dir, cycle="qa", cycle_number=None, check="architect-review", control_id=None,
|
|
274
|
+
module=module_name, submodule=None, criterion_id=None,
|
|
275
|
+
files=changed_files if mode == "diff" else [],
|
|
276
|
+
result="block" if result.get("blocks", 0) > 0 else "pass",
|
|
277
|
+
errors=[f.get("finding", "") for f in result.get("findings", []) if f.get("severity") == "BLOCK"],
|
|
278
|
+
error_count=result.get("blocks", 0),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if output_json:
|
|
282
|
+
click.echo(json.dumps(result, indent=2))
|
|
283
|
+
if fail_on_block and result.get("blocks", 0) > 0:
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
sys.exit(0)
|
|
286
|
+
|
|
287
|
+
_render_table(result)
|
|
288
|
+
|
|
289
|
+
if fail_on_block and result.get("blocks", 0) > 0:
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
sys.exit(0)
|