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,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
+ )