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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""pcp run-log — pre/post audit bracket for any development or test run,
|
|
2
|
+
manual or automated. Ganesh, 2026-07-23: "record entry pre and post any
|
|
3
|
+
development run, test run or anything ... so we get a full grip of where
|
|
4
|
+
the bluffing is happening." `pcp build` wires start_run/end_run
|
|
5
|
+
automatically with real usage data; this CLI is for bracketing manual /
|
|
6
|
+
interactive work, which is otherwise invisible to token_ledger.yaml and
|
|
7
|
+
telemetry.jsonl entirely (see project memory,
|
|
8
|
+
project_ontology_foundry_stale_snapshot_2026_07_23 and this session's own
|
|
9
|
+
token_ledger gap).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
21
|
+
from pcp import run_log
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_pcp_dir(project_path: str | None) -> Path:
|
|
27
|
+
try:
|
|
28
|
+
return find_pcp_dir(Path(project_path) if project_path else None)
|
|
29
|
+
except NoPCPDir as e:
|
|
30
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
31
|
+
sys.exit(2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group("run-log")
|
|
35
|
+
def run_log_cli():
|
|
36
|
+
"""Pre/post audit bracket around any development or test run."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@run_log_cli.command("start")
|
|
40
|
+
@click.option("--module", required=True, help="Module this run is working on.")
|
|
41
|
+
@click.option("--feature", required=True, help="Feature/criterion this run addresses.")
|
|
42
|
+
@click.option("--type", "run_type", type=click.Choice(["dev", "test", "build", "gate", "manual"]), default="dev")
|
|
43
|
+
@click.option("--actor", default="human-interactive", help="Who is actually doing the work.")
|
|
44
|
+
@click.option("--model", default=None, help="Model driving this run, if known up front.")
|
|
45
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
46
|
+
def start(module: str, feature: str, run_type: str, actor: str, model: str | None, project_path: str | None):
|
|
47
|
+
"""Open a run — writes the PRE record, prints the run_id to close it with."""
|
|
48
|
+
pcp_dir = _resolve_pcp_dir(project_path)
|
|
49
|
+
run_id = run_log.start_run(
|
|
50
|
+
pcp_dir, module=module, feature=feature, run_type=run_type, actor=actor, model=model,
|
|
51
|
+
)
|
|
52
|
+
console.print(f"[green]Run started:[/green] {run_id}")
|
|
53
|
+
console.print(f"[dim]Close it: pcp run-log end --run-id {run_id} --result success ...[/dim]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@run_log_cli.command("end")
|
|
57
|
+
@click.option("--run-id", required=True)
|
|
58
|
+
@click.option("--result", type=click.Choice(["success", "failure", "partial"]), required=True)
|
|
59
|
+
@click.option("--model", default=None)
|
|
60
|
+
@click.option("--tokens-in", "token_input", type=int, default=0)
|
|
61
|
+
@click.option("--tokens-out", "token_output", type=int, default=0)
|
|
62
|
+
@click.option("--cache-read", "token_cache_read", type=int, default=0)
|
|
63
|
+
@click.option("--cost", "cost_usd", type=float, default=None)
|
|
64
|
+
@click.option("--tests-passed/--tests-failed", "tests_passed", default=None,
|
|
65
|
+
help="Omit if no test suite ran this run — the ledger records that gap explicitly, it does not guess.")
|
|
66
|
+
@click.option("--real-gate", "real_gates_passed", multiple=True,
|
|
67
|
+
help="Name a deterministic check (subprocess/AST — not an LLM opinion) that actually ran. Repeatable.")
|
|
68
|
+
@click.option("--llm-gate", "llm_judged_gates_passed", multiple=True,
|
|
69
|
+
help="Name an LLM-judged check that passed. Repeatable.")
|
|
70
|
+
@click.option("--note", default=None, help="Free-text proof-of-delivery note (what was actually verified).")
|
|
71
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
72
|
+
def end(run_id: str, result: str, model: str | None, token_input: int, token_output: int,
|
|
73
|
+
token_cache_read: int, cost_usd: float | None, tests_passed: bool | None,
|
|
74
|
+
real_gates_passed: tuple, llm_judged_gates_passed: tuple, note: str | None, project_path: str | None):
|
|
75
|
+
"""Close a run — writes the POST record, prints any anomaly flags."""
|
|
76
|
+
pcp_dir = _resolve_pcp_dir(project_path)
|
|
77
|
+
entry = run_log.end_run(
|
|
78
|
+
pcp_dir, run_id, result=result, model=model,
|
|
79
|
+
token_input=token_input, token_output=token_output, token_cache_read=token_cache_read,
|
|
80
|
+
cost_usd=cost_usd, tests_ran=tests_passed is not None, tests_passed=tests_passed,
|
|
81
|
+
real_gates_passed=list(real_gates_passed), llm_judged_gates_passed=list(llm_judged_gates_passed),
|
|
82
|
+
note=note, self_reported_usage=True,
|
|
83
|
+
)
|
|
84
|
+
console.print(f"[green]Run closed:[/green] {run_id} — result={result}")
|
|
85
|
+
if entry["anomaly_flags"]:
|
|
86
|
+
console.print("[yellow bold]Anomalies flagged:[/yellow bold]")
|
|
87
|
+
for a in entry["anomaly_flags"]:
|
|
88
|
+
console.print(f" ⚠ {a}")
|
|
89
|
+
else:
|
|
90
|
+
console.print("[dim]No anomalies.[/dim]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@run_log_cli.command("list")
|
|
94
|
+
@click.option("--path", "project_path", type=click.Path(), default=None)
|
|
95
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
96
|
+
@click.option("--limit", type=int, default=30)
|
|
97
|
+
def list_runs(project_path: str | None, as_json: bool, limit: int):
|
|
98
|
+
"""Show recent runs, paired pre+post, with any anomaly flags."""
|
|
99
|
+
pcp_dir = _resolve_pcp_dir(project_path)
|
|
100
|
+
records = run_log.load(pcp_dir)
|
|
101
|
+
pairs = run_log.pair_runs(records)
|
|
102
|
+
open_ = run_log.open_runs(records)
|
|
103
|
+
|
|
104
|
+
if as_json:
|
|
105
|
+
console.print(json.dumps({"runs": pairs, "open_runs": open_}, indent=2))
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
table = Table(title="Run Ledger")
|
|
109
|
+
for col in ["run_id", "module", "feature", "actor", "type", "result", "committed", "anomalies"]:
|
|
110
|
+
table.add_column(col)
|
|
111
|
+
for p in pairs[-limit:]:
|
|
112
|
+
proof = p.get("proof_of_delivery", {})
|
|
113
|
+
table.add_row(
|
|
114
|
+
str(p.get("run_id", ""))[:8], str(p.get("module", "")), str(p.get("feature", ""))[:30],
|
|
115
|
+
str(p.get("actor", "")), str(p.get("run_type", "")), str(p.get("result", "")),
|
|
116
|
+
str(proof.get("committed")), ", ".join(p.get("anomaly_flags", [])) or "-",
|
|
117
|
+
)
|
|
118
|
+
console.print(table)
|
|
119
|
+
if open_:
|
|
120
|
+
console.print(f"\n[yellow]{len(open_)} open run(s) never closed:[/yellow]")
|
|
121
|
+
for r in open_:
|
|
122
|
+
console.print(f" {r.get('run_id', '')[:8]} — {r.get('module')}/{r.get('feature')} started {r.get('start_time')}")
|
pcp/commands/scan.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""pcp scan — auto-generate current_state.md from acceptance criteria."""
|
|
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
|
+
from pcp.commands.diff import run_diff
|
|
15
|
+
from pcp.discovery.scanner import detect_stack, collect_source_files
|
|
16
|
+
from pcp import qa
|
|
17
|
+
from pcp import uat
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
# Per-process caches: one `pcp scan` invocation is one process, so these are
|
|
22
|
+
# safe to keep for the lifetime of the run and avoid re-globbing/re-reading
|
|
23
|
+
# the whole repo once per acceptance criterion.
|
|
24
|
+
_SOURCE_FILES_CACHE: dict[Path, list[Path]] = {}
|
|
25
|
+
_FILE_CONTENT_CACHE: dict[Path, str] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _project_source_files(project_root: Path) -> list[Path]:
|
|
29
|
+
cached = _SOURCE_FILES_CACHE.get(project_root)
|
|
30
|
+
if cached is None:
|
|
31
|
+
stack = detect_stack(project_root)
|
|
32
|
+
cached = collect_source_files(project_root, stack)
|
|
33
|
+
_SOURCE_FILES_CACHE[project_root] = cached
|
|
34
|
+
return cached
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_cached(path: Path) -> str:
|
|
38
|
+
content = _FILE_CONTENT_CACHE.get(path)
|
|
39
|
+
if content is None:
|
|
40
|
+
content = path.read_text(errors="replace")
|
|
41
|
+
_FILE_CONTENT_CACHE[path] = content
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_file_exists(target: str, project_root: Path) -> tuple[bool, str]:
|
|
46
|
+
path = project_root / target
|
|
47
|
+
if path.exists():
|
|
48
|
+
return True, str(path.relative_to(project_root))
|
|
49
|
+
|
|
50
|
+
# Fallback: file may have moved/been renamed during a refactor.
|
|
51
|
+
# Search for a same-basename file elsewhere in the tree before failing.
|
|
52
|
+
basename = Path(target).name
|
|
53
|
+
for f in _project_source_files(project_root):
|
|
54
|
+
if f.name == basename:
|
|
55
|
+
rel = f.relative_to(project_root)
|
|
56
|
+
return True, f"{target}: not at declared path, found moved to {rel}"
|
|
57
|
+
|
|
58
|
+
return False, f"{target}: not found (declared path or elsewhere)"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check_ast_pattern(target: str, pattern: str, project_root: Path) -> tuple[bool, str]:
|
|
62
|
+
path = project_root / target
|
|
63
|
+
# Real bug, found 2026-07-08: path.exists() is True for a directory too,
|
|
64
|
+
# and directories don't have text content -- _read_cached(path).read_text()
|
|
65
|
+
# raised an unhandled IsADirectoryError that crashed the whole `pcp scan`
|
|
66
|
+
# run outright, whenever any acceptance criterion's ast_pattern target
|
|
67
|
+
# happened to be a directory rather than a file. is_file() correctly
|
|
68
|
+
# falls through to the same repo-wide fallback search used for a target
|
|
69
|
+
# that's simply missing.
|
|
70
|
+
if path.is_file():
|
|
71
|
+
if re.search(pattern, _read_cached(path), re.MULTILINE):
|
|
72
|
+
return True, f"pattern found in {target}"
|
|
73
|
+
|
|
74
|
+
# Fallback: feature may have been absorbed into a differently-named file
|
|
75
|
+
# during a refactor. Search other source files for the same pattern
|
|
76
|
+
# before declaring the criterion incomplete.
|
|
77
|
+
for f in _project_source_files(project_root):
|
|
78
|
+
if f == path:
|
|
79
|
+
continue
|
|
80
|
+
if re.search(pattern, _read_cached(f), re.MULTILINE):
|
|
81
|
+
rel = f.relative_to(project_root)
|
|
82
|
+
return True, f"pattern not found in {target}, found instead in {rel} — spec target may be stale"
|
|
83
|
+
|
|
84
|
+
return False, f"pattern not found in {target} or elsewhere in repo"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_prior_manual_status(current_state_path: Path) -> dict[str, str]:
|
|
88
|
+
"""Parse prior current_state.md to preserve manual criterion statuses."""
|
|
89
|
+
if not current_state_path.exists():
|
|
90
|
+
return {}
|
|
91
|
+
statuses = {}
|
|
92
|
+
content = current_state_path.read_text()
|
|
93
|
+
for line in content.splitlines():
|
|
94
|
+
# Format: `- [x] MODULE/ID: description` or `- [ ] MODULE/ID: description`
|
|
95
|
+
m = re.match(r"- \[([ x])\] ([A-Z]+/[A-Z][0-9]+):", line.strip())
|
|
96
|
+
if m:
|
|
97
|
+
done = m.group(1) == "x"
|
|
98
|
+
key = m.group(2)
|
|
99
|
+
statuses[key] = "complete" if done else "pending"
|
|
100
|
+
return statuses
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _evaluate_criterion(
|
|
104
|
+
criterion: dict,
|
|
105
|
+
module_name: str,
|
|
106
|
+
project_root: Path,
|
|
107
|
+
prior_manual: dict[str, str],
|
|
108
|
+
spec: dict,
|
|
109
|
+
pcp_dir: Path | None = None,
|
|
110
|
+
) -> tuple[str, str]:
|
|
111
|
+
"""Returns (status, detail)."""
|
|
112
|
+
cid = criterion["id"]
|
|
113
|
+
check = criterion.get("check", "manual")
|
|
114
|
+
target = criterion.get("target", "")
|
|
115
|
+
pattern = criterion.get("pattern", "")
|
|
116
|
+
|
|
117
|
+
if check == "file_exists":
|
|
118
|
+
ok, detail = _check_file_exists(target, project_root)
|
|
119
|
+
return ("complete" if ok else "pending"), detail
|
|
120
|
+
|
|
121
|
+
elif check == "ast_pattern":
|
|
122
|
+
if not pattern:
|
|
123
|
+
return criterion.get("status", "pending"), "no pattern defined"
|
|
124
|
+
ok, detail = _check_ast_pattern(target, pattern, project_root)
|
|
125
|
+
return ("complete" if ok else "pending"), detail
|
|
126
|
+
|
|
127
|
+
elif check == "test_passes":
|
|
128
|
+
return criterion.get("status", "pending"), "test_passes: preserved (run tests to update)"
|
|
129
|
+
|
|
130
|
+
elif check == "url_responds":
|
|
131
|
+
ok, detail = uat.check_url_responds(criterion.get("url", ""))
|
|
132
|
+
return ("complete" if ok else "pending"), detail
|
|
133
|
+
|
|
134
|
+
elif check == "dom_contains":
|
|
135
|
+
ok, detail = uat.check_dom_contains(criterion.get("url", ""), criterion.get("selector", ""))
|
|
136
|
+
return ("complete" if ok else "pending"), detail
|
|
137
|
+
|
|
138
|
+
elif check == "visual":
|
|
139
|
+
screenshot_path = None
|
|
140
|
+
if pcp_dir:
|
|
141
|
+
screenshot_path = pcp_dir / "evidence" / "_visual" / module_name / f"{cid}.png"
|
|
142
|
+
ok, detail = uat.check_visual(criterion.get("url", ""), screenshot_path)
|
|
143
|
+
if ok is None:
|
|
144
|
+
# Optional dependency not installed -- "could not check", not a
|
|
145
|
+
# verdict. Preserve prior status rather than downgrading it, same
|
|
146
|
+
# posture the manual fallback below already uses.
|
|
147
|
+
return criterion.get("status", "pending"), detail
|
|
148
|
+
return ("complete" if ok else "pending"), detail
|
|
149
|
+
|
|
150
|
+
else: # manual
|
|
151
|
+
key = f"{module_name.upper()}/{cid}"
|
|
152
|
+
prior = prior_manual.get(key)
|
|
153
|
+
if prior:
|
|
154
|
+
return prior, "manual (preserved from prior scan)"
|
|
155
|
+
return criterion.get("status", "pending"), "manual"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _scan_module(
|
|
159
|
+
module_name: str,
|
|
160
|
+
acceptance_path: Path,
|
|
161
|
+
project_root: Path,
|
|
162
|
+
prior_manual: dict[str, str],
|
|
163
|
+
pcp_dir: Path | None = None,
|
|
164
|
+
) -> dict:
|
|
165
|
+
errors = validate_file(acceptance_path, "module_acceptance")
|
|
166
|
+
if errors:
|
|
167
|
+
console.print(f"[yellow]⚠ {module_name}/acceptance.yaml: schema errors[/yellow]")
|
|
168
|
+
for e in errors:
|
|
169
|
+
console.print(f" {e}")
|
|
170
|
+
|
|
171
|
+
data = load_yaml(acceptance_path)
|
|
172
|
+
criteria = data.get("criteria", [])
|
|
173
|
+
results = []
|
|
174
|
+
|
|
175
|
+
for c in criteria:
|
|
176
|
+
status, detail = _evaluate_criterion(c, module_name, project_root, prior_manual, data, pcp_dir)
|
|
177
|
+
results.append({
|
|
178
|
+
"id": c["id"],
|
|
179
|
+
"description": c["description"],
|
|
180
|
+
"check": c.get("check", "manual"),
|
|
181
|
+
"status": status,
|
|
182
|
+
"detail": detail,
|
|
183
|
+
"verified_by": c.get("verified_by"),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return {"module": module_name, "criteria": results}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _write_current_state(pcp_dir: Path, modules_results: list[dict], timestamp: str, coverage: dict | None = None) -> None:
|
|
190
|
+
total = sum(len(m["criteria"]) for m in modules_results)
|
|
191
|
+
complete = sum(
|
|
192
|
+
1 for m in modules_results for c in m["criteria"] if c["status"] == "complete"
|
|
193
|
+
)
|
|
194
|
+
score = complete / total if total else 0.0
|
|
195
|
+
|
|
196
|
+
lines = [
|
|
197
|
+
"# Current State",
|
|
198
|
+
f"Generated: {timestamp}",
|
|
199
|
+
"",
|
|
200
|
+
"## Summary",
|
|
201
|
+
f"- Total criteria: {total}",
|
|
202
|
+
f"- Complete: {complete} ({score:.0%})",
|
|
203
|
+
f"- Pending: {total - complete}",
|
|
204
|
+
"",
|
|
205
|
+
"## Module Status",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
for m in modules_results:
|
|
209
|
+
lines.append(f"\n### {m['module']}")
|
|
210
|
+
for c in m["criteria"]:
|
|
211
|
+
mark = "x" if c["status"] == "complete" else " "
|
|
212
|
+
key = f"{m['module'].upper()}/{c['id']}"
|
|
213
|
+
verified = f" [verified: {c['verified_by']}]" if c["status"] == "complete" and c.get("verified_by") else (
|
|
214
|
+
" [unverified — not marked complete by pcp build]" if c["status"] == "complete" else ""
|
|
215
|
+
)
|
|
216
|
+
lines.append(f"- [{mark}] {key}: {c['description']}{verified}")
|
|
217
|
+
if c["detail"] and c["detail"] not in ("manual", ""):
|
|
218
|
+
lines.append(f" > {c['detail']}")
|
|
219
|
+
|
|
220
|
+
lines += [
|
|
221
|
+
"",
|
|
222
|
+
"## Drift Score",
|
|
223
|
+
f"acceptance coverage: {score:.2f}",
|
|
224
|
+
"",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
if coverage and coverage.get("tool") and coverage.get("percent") is not None:
|
|
228
|
+
lines += [
|
|
229
|
+
"## Test Coverage",
|
|
230
|
+
f"{coverage['percent']:.0f}% ({coverage['tool']})",
|
|
231
|
+
"",
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
out = pcp_dir / "current_state.md"
|
|
235
|
+
out.write_text("\n".join(lines))
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@click.command()
|
|
240
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
241
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
242
|
+
@click.option("--quiet", is_flag=True, help="Suppress output.")
|
|
243
|
+
@click.option("--coverage", "with_coverage", is_flag=True,
|
|
244
|
+
help="Also run the test suite under coverage and record %% covered. "
|
|
245
|
+
"Off by default — runs the full suite, slower than a plain scan.")
|
|
246
|
+
def scan(project_path: str | None, quiet: bool, with_coverage: bool):
|
|
247
|
+
"""Auto-generate .pcp/current_state.md from acceptance criteria."""
|
|
248
|
+
try:
|
|
249
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
250
|
+
except NoPCPDir as e:
|
|
251
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
252
|
+
sys.exit(2)
|
|
253
|
+
|
|
254
|
+
project_root = pcp_dir.parent
|
|
255
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
256
|
+
|
|
257
|
+
if not modules_dir.exists():
|
|
258
|
+
console.print("[yellow]No modules found in .pcp/strategy/modules/.[/yellow]")
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
|
|
261
|
+
current_state_path = pcp_dir / "current_state.md"
|
|
262
|
+
prior_manual = _load_prior_manual_status(current_state_path)
|
|
263
|
+
|
|
264
|
+
acceptance_files = sorted(modules_dir.glob("*/acceptance.yaml"))
|
|
265
|
+
if not acceptance_files:
|
|
266
|
+
console.print("[yellow]No acceptance.yaml files found.[/yellow]")
|
|
267
|
+
sys.exit(0)
|
|
268
|
+
|
|
269
|
+
modules_results = []
|
|
270
|
+
for af in acceptance_files:
|
|
271
|
+
module_name = af.parent.name
|
|
272
|
+
result = _scan_module(module_name, af, project_root, prior_manual, pcp_dir)
|
|
273
|
+
modules_results.append(result)
|
|
274
|
+
|
|
275
|
+
coverage = None
|
|
276
|
+
if with_coverage:
|
|
277
|
+
if not quiet:
|
|
278
|
+
console.print("[dim]Running test suite under coverage...[/dim]")
|
|
279
|
+
coverage = qa.run_coverage(project_root)
|
|
280
|
+
if not quiet and not coverage.get("tool"):
|
|
281
|
+
console.print("[dim]No coverage tool detected (coverage/pytest or npm coverage script) — skipped.[/dim]")
|
|
282
|
+
|
|
283
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
284
|
+
out_path = _write_current_state(pcp_dir, modules_results, timestamp, coverage)
|
|
285
|
+
|
|
286
|
+
# Work that landed but whose status still says pending. `scan` is the right
|
|
287
|
+
# place: it is what regenerates current_state.md, so it is the moment a stale
|
|
288
|
+
# status starts propagating into the dashboard, diff.md, validate-strategy's
|
|
289
|
+
# coverage and the next build's pending list. Reported, never auto-corrected —
|
|
290
|
+
# acceptance.yaml is human-approved, and flipping status here would trade a
|
|
291
|
+
# false "not built" for a false "built", which is strictly worse.
|
|
292
|
+
if not quiet:
|
|
293
|
+
from pcp import orphaned_work
|
|
294
|
+
try:
|
|
295
|
+
orphaned = orphaned_work.find_orphaned_work(pcp_dir, project_root)
|
|
296
|
+
except Exception:
|
|
297
|
+
orphaned = []
|
|
298
|
+
if orphaned:
|
|
299
|
+
console.print("")
|
|
300
|
+
for line in orphaned_work.format_findings(orphaned):
|
|
301
|
+
console.print(f"[yellow]{line}[/yellow]" if not line.startswith(" ") else f"[dim]{line}[/dim]")
|
|
302
|
+
|
|
303
|
+
# Mirror image of the above: complete with no evidence a gate ever ran.
|
|
304
|
+
# More dangerous than a stale pending -- a false complete means the
|
|
305
|
+
# work is never checked again. Advisory only; see
|
|
306
|
+
# find_unverified_complete's own docstring for why this never blocks.
|
|
307
|
+
try:
|
|
308
|
+
unverified = orphaned_work.find_unverified_complete(pcp_dir)
|
|
309
|
+
except Exception:
|
|
310
|
+
unverified = []
|
|
311
|
+
if unverified:
|
|
312
|
+
console.print("")
|
|
313
|
+
for line in orphaned_work.format_unverified_findings(unverified):
|
|
314
|
+
console.print(f"[yellow]{line}[/yellow]" if not line.startswith(" ") else f"[dim]{line}[/dim]")
|
|
315
|
+
|
|
316
|
+
# diff.md was previously only ever regenerated by a human remembering to
|
|
317
|
+
# run `pcp diff` separately -- silently skips if target_state.md doesn't
|
|
318
|
+
# exist yet, same tolerance the standalone command already had.
|
|
319
|
+
diff_result = run_diff(pcp_dir)
|
|
320
|
+
if not quiet and diff_result and diff_result["regressions"]:
|
|
321
|
+
console.print(f"[red]{len(diff_result['regressions'])} regression(s) in diff.md[/red]")
|
|
322
|
+
|
|
323
|
+
# Per-symbol fingerprint churn (build plan 3.4): symbol-level, not
|
|
324
|
+
# file-level, change signal — "file touched but relevant symbol
|
|
325
|
+
# unchanged" stops counting as drift noise.
|
|
326
|
+
try:
|
|
327
|
+
from pcp import symbols
|
|
328
|
+
delta = symbols.update_fingerprints(pcp_dir)
|
|
329
|
+
n_changed = len(delta["changed"])
|
|
330
|
+
if not quiet and (n_changed or delta["added"] or delta["removed"]):
|
|
331
|
+
console.print(
|
|
332
|
+
f"[dim]Symbols since last scan: {n_changed} changed, "
|
|
333
|
+
f"{len(delta['added'])} added, {len(delta['removed'])} removed[/dim]"
|
|
334
|
+
)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
total = sum(len(m["criteria"]) for m in modules_results)
|
|
339
|
+
complete = sum(1 for m in modules_results for c in m["criteria"] if c["status"] == "complete")
|
|
340
|
+
|
|
341
|
+
pcp_md_path = write_pcp_md(pcp_dir, modules_results, timestamp, total, complete)
|
|
342
|
+
|
|
343
|
+
if not quiet:
|
|
344
|
+
score = complete / total if total else 0.0
|
|
345
|
+
color = "green" if score >= 0.8 else "yellow" if score >= 0.5 else "red"
|
|
346
|
+
console.print(f"[{color}]{complete}/{total} criteria complete ({score:.0%})[/{color}] → {out_path.relative_to(project_root)} + {pcp_md_path.name}")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`pcp self-update` -- explicit, human-run, git-pull-based update.
|
|
2
|
+
|
|
3
|
+
Deliberately NOT the shape removed as a launch blocker on 2026-07-27: that
|
|
4
|
+
cron silently curled a mutable file off raw.githubusercontent.com and
|
|
5
|
+
overwrote an agent instruction file with no signature, no hash check, no
|
|
6
|
+
human trigger. This command is the opposite on every axis: only runs when
|
|
7
|
+
a human types it, only touches a real git checkout (refuses cleanly
|
|
8
|
+
otherwise), only ever fast-forwards (never clobbers local changes), and
|
|
9
|
+
reports exactly what changed -- same shape Project P landed on for its own
|
|
10
|
+
agent self-update (`git pull --ff-only`, see decision_git_pull_self_update).
|
|
11
|
+
|
|
12
|
+
`--check` is the announce-only half, added for the SessionStart hook
|
|
13
|
+
(.pcp/hooks/session_update_check.py, scaffolded by `pcp init`): a project
|
|
14
|
+
opening a session can tell you an update exists without ever pulling one
|
|
15
|
+
byte on its own. Actually updating always stays a separate, explicit
|
|
16
|
+
`pcp self-update` invocation.
|
|
17
|
+
"""
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from pcp import version_drift
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
30
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_checkout() -> tuple[Path | None, str | None]:
|
|
34
|
+
"""Returns (root, error_message). error_message is None on success."""
|
|
35
|
+
root = version_drift.source_root()
|
|
36
|
+
if root is None:
|
|
37
|
+
return None, (
|
|
38
|
+
"No git checkout found for this install. self-update only works on a "
|
|
39
|
+
"git-cloned source tree (set PCP_SOURCE_ROOT if this one lives somewhere non-standard)."
|
|
40
|
+
)
|
|
41
|
+
if not (root / ".git").exists():
|
|
42
|
+
return None, f"{root} is not a git repository. Cannot self-update."
|
|
43
|
+
return root, None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_for_update() -> dict:
|
|
47
|
+
"""Read-only: fetches from origin and reports whether the local checkout
|
|
48
|
+
is behind, without touching the working tree. Never pulls."""
|
|
49
|
+
root, err = _resolve_checkout()
|
|
50
|
+
if root is None:
|
|
51
|
+
return {"status": "unavailable", "message": err}
|
|
52
|
+
|
|
53
|
+
fetch = _git(["fetch", "--quiet"], root)
|
|
54
|
+
if fetch.returncode != 0:
|
|
55
|
+
return {"status": "unknown", "message": f"git fetch failed: {(fetch.stderr or fetch.stdout).strip()}"}
|
|
56
|
+
|
|
57
|
+
behind = _git(["rev-list", "--count", "HEAD..@{u}"], root)
|
|
58
|
+
if behind.returncode != 0:
|
|
59
|
+
# No upstream tracking branch configured -- can't tell.
|
|
60
|
+
return {"status": "unknown", "message": "No upstream tracking branch configured."}
|
|
61
|
+
|
|
62
|
+
count = int(behind.stdout.strip() or "0")
|
|
63
|
+
if count == 0:
|
|
64
|
+
return {"status": "current", "message": "PCP is up to date."}
|
|
65
|
+
|
|
66
|
+
current = version_drift.source_version(root) or "unknown"
|
|
67
|
+
return {
|
|
68
|
+
"status": "behind", "commits_behind": count, "current_version": current,
|
|
69
|
+
"message": f"PCP update available ({count} commit(s) behind) -- run `pcp self-update`.",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.command(name="self-update")
|
|
74
|
+
@click.option("--check", "check_only", is_flag=True,
|
|
75
|
+
help="Report whether an update is available without pulling it.")
|
|
76
|
+
def self_update(check_only: bool):
|
|
77
|
+
"""Update this PCP install via `git pull --ff-only` on its own checkout."""
|
|
78
|
+
if check_only:
|
|
79
|
+
result = check_for_update()
|
|
80
|
+
console.print(result["message"])
|
|
81
|
+
raise SystemExit(0 if result["status"] != "unavailable" else 1)
|
|
82
|
+
|
|
83
|
+
root, err = _resolve_checkout()
|
|
84
|
+
if root is None:
|
|
85
|
+
console.print(f"[red]{err}[/red]")
|
|
86
|
+
raise SystemExit(1)
|
|
87
|
+
|
|
88
|
+
status = _git(["status", "--porcelain"], root)
|
|
89
|
+
if status.stdout.strip():
|
|
90
|
+
console.print(
|
|
91
|
+
f"[yellow]{root} has uncommitted changes.[/yellow] "
|
|
92
|
+
"Commit or stash them first -- self-update refuses to pull over local edits."
|
|
93
|
+
)
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
|
|
96
|
+
before = version_drift.source_version(root) or "unknown"
|
|
97
|
+
console.print(f"Current source version: {before}")
|
|
98
|
+
console.print("Running `git pull --ff-only`...")
|
|
99
|
+
|
|
100
|
+
pull = _git(["pull", "--ff-only"], root)
|
|
101
|
+
if pull.returncode != 0:
|
|
102
|
+
console.print("[red]git pull --ff-only failed:[/red]")
|
|
103
|
+
console.print(pull.stdout + pull.stderr)
|
|
104
|
+
console.print(
|
|
105
|
+
"[dim]Common cause: local commits diverged from origin. "
|
|
106
|
+
"Resolve manually (rebase/merge) -- self-update will not force it.[/dim]"
|
|
107
|
+
)
|
|
108
|
+
raise SystemExit(1)
|
|
109
|
+
|
|
110
|
+
after = version_drift.source_version(root) or "unknown"
|
|
111
|
+
if pull.stdout.strip():
|
|
112
|
+
console.print(pull.stdout.strip())
|
|
113
|
+
|
|
114
|
+
if before == after:
|
|
115
|
+
console.print(f"[green]Already up to date[/green] ({after}).")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
console.print(f"[green]Updated {before} -> {after}.[/green]")
|
|
119
|
+
if version_drift.is_editable():
|
|
120
|
+
console.print("Editable install -- code is live now, no reinstall needed.")
|
|
121
|
+
else:
|
|
122
|
+
console.print(
|
|
123
|
+
f"[yellow]This is a wheel install[/yellow] -- source is updated at {root}, "
|
|
124
|
+
f"but reinstall to pick it up: `pip install -e {root}`."
|
|
125
|
+
)
|