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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/discovery/graph.py ADDED
@@ -0,0 +1,110 @@
1
+ """Build import dependency graph from source files."""
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ def extract_imports_python(path: Path, root: Path) -> list[str]:
9
+ try:
10
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
11
+ except SyntaxError:
12
+ return []
13
+
14
+ imports = []
15
+ for node in ast.walk(tree):
16
+ if isinstance(node, ast.Import):
17
+ for alias in node.names:
18
+ imports.append(alias.name.split(".")[0])
19
+ elif isinstance(node, ast.ImportFrom):
20
+ if node.module:
21
+ imports.append(node.module.split(".")[0])
22
+ return imports
23
+
24
+
25
+ def extract_imports_ts_js(path: Path, root: Path) -> list[str]:
26
+ text = path.read_text(encoding="utf-8", errors="replace")
27
+ # import ... from '...' or require('...')
28
+ patterns = [
29
+ r'from\s+[\'"]([^\'"\s]+)[\'"]',
30
+ r'require\s*\(\s*[\'"]([^\'"\s]+)[\'"]\s*\)',
31
+ r'import\s*\(\s*[\'"]([^\'"\s]+)[\'"]\s*\)',
32
+ ]
33
+ imports = []
34
+ for pattern in patterns:
35
+ for match in re.finditer(pattern, text):
36
+ imp = match.group(1)
37
+ if imp.startswith("."):
38
+ # relative — resolve to a sibling file/dir name
39
+ resolved = (path.parent / imp).resolve()
40
+ try:
41
+ imp = str(resolved.relative_to(root)).split("/")[0]
42
+ except ValueError:
43
+ continue
44
+ else:
45
+ imp = imp.split("/")[0].lstrip("@")
46
+ imports.append(imp)
47
+ return imports
48
+
49
+
50
+ def extract_imports_rust(path: Path, root: Path) -> list[str]:
51
+ text = path.read_text(encoding="utf-8", errors="replace")
52
+ imports = []
53
+ for match in re.finditer(r'\buse\s+([\w:]+)', text):
54
+ crate = match.group(1).split("::")[0]
55
+ imports.append(crate)
56
+ for match in re.finditer(r'\bextern\s+crate\s+(\w+)', text):
57
+ imports.append(match.group(1))
58
+ return imports
59
+
60
+
61
+ def extract_imports_go(path: Path, root: Path) -> list[str]:
62
+ text = path.read_text(encoding="utf-8", errors="replace")
63
+ imports = []
64
+ for match in re.finditer(r'"([^"]+)"', text):
65
+ pkg = match.group(1)
66
+ parts = pkg.split("/")
67
+ # local package = last path component
68
+ imports.append(parts[-1])
69
+ return imports
70
+
71
+
72
+ EXTRACTORS = {
73
+ ".py": extract_imports_python,
74
+ ".ts": extract_imports_ts_js,
75
+ ".tsx": extract_imports_ts_js,
76
+ ".js": extract_imports_ts_js,
77
+ ".jsx": extract_imports_ts_js,
78
+ ".rs": extract_imports_rust,
79
+ ".go": extract_imports_go,
80
+ }
81
+
82
+
83
+ def build_dependency_graph(files: list[Path], root: Path) -> dict[str, set[str]]:
84
+ """
85
+ Returns adjacency dict: {file_key: {imported_file_key, ...}}
86
+ file_key = path relative to root, using '/' separator
87
+ Only edges between files in the project (not external packages).
88
+ """
89
+ file_keys = {f: str(f.relative_to(root)) for f in files}
90
+ # index by stem and by relative path for resolution
91
+ stem_index: dict[str, str] = {}
92
+ for f, key in file_keys.items():
93
+ stem_index[f.stem] = key
94
+ stem_index[str(f.relative_to(root))] = key
95
+
96
+ graph: dict[str, set[str]] = {key: set() for key in file_keys.values()}
97
+
98
+ for f, key in file_keys.items():
99
+ extractor = EXTRACTORS.get(f.suffix)
100
+ if not extractor:
101
+ continue
102
+ imports = extractor(f, root)
103
+ for imp in imports:
104
+ # resolve to a known file key
105
+ target = stem_index.get(imp) or stem_index.get(imp + ".py") or \
106
+ stem_index.get(imp + ".ts") or stem_index.get(imp + ".js")
107
+ if target and target != key:
108
+ graph[key].add(target)
109
+
110
+ return graph
@@ -0,0 +1,109 @@
1
+ """Stack detection and file inventory for brownfield import."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ STACK_SIGNALS = {
8
+ "python": ["pyproject.toml", "setup.py", "requirements.txt", "Pipfile"],
9
+ "typescript": ["tsconfig.json", "package.json"],
10
+ "javascript": ["package.json"],
11
+ "rust": ["Cargo.toml"],
12
+ "swift": ["Package.swift", "*.xcodeproj"],
13
+ "go": ["go.mod"],
14
+ "java": ["pom.xml", "build.gradle"],
15
+ "ruby": ["Gemfile"],
16
+ }
17
+
18
+ SOURCE_EXTENSIONS = {
19
+ "python": [".py"],
20
+ "typescript": [".ts", ".tsx"],
21
+ "javascript": [".js", ".jsx"],
22
+ "rust": [".rs"],
23
+ "swift": [".swift"],
24
+ "go": [".go"],
25
+ "java": [".java"],
26
+ "ruby": [".rb"],
27
+ }
28
+
29
+ IGNORE_DIRS = {
30
+ ".git", ".pcp", "node_modules", "__pycache__", ".venv", "venv",
31
+ "dist", "build", ".build", "target", ".next", ".nuxt", "coverage",
32
+ ".tox", "eggs", "*.egg-info",
33
+ }
34
+
35
+
36
+ def detect_stack(root: Path) -> list[str]:
37
+ detected = []
38
+ for lang, signals in STACK_SIGNALS.items():
39
+ for signal in signals:
40
+ if signal.startswith("*"):
41
+ if list(root.glob(f"**/{signal}")):
42
+ detected.append(lang)
43
+ break
44
+ else:
45
+ if (root / signal).exists():
46
+ detected.append(lang)
47
+ break
48
+ # deduplicate: ts implies js, prefer ts
49
+ if "typescript" in detected and "javascript" in detected:
50
+ detected.remove("javascript")
51
+ return detected or ["unknown"]
52
+
53
+
54
+ def collect_source_files(root: Path, stack: list[str]) -> list[Path]:
55
+ extensions = set()
56
+ for lang in stack:
57
+ extensions.update(SOURCE_EXTENSIONS.get(lang, []))
58
+ if not extensions:
59
+ extensions = {".py", ".ts", ".tsx", ".js", ".jsx", ".rs", ".swift", ".go"}
60
+
61
+ files = []
62
+ for path in root.rglob("*"):
63
+ if any(part in IGNORE_DIRS or part.endswith(".egg-info") for part in path.parts):
64
+ continue
65
+ if path.suffix in extensions and path.is_file():
66
+ files.append(path)
67
+ return sorted(files)
68
+
69
+
70
+ def detect_entry_points(root: Path, stack: list[str]) -> list[str]:
71
+ entries = []
72
+ candidates = [
73
+ "main.py", "app.py", "server.py", "wsgi.py", "asgi.py",
74
+ "index.ts", "index.js", "main.ts", "server.ts", "app.ts",
75
+ "src/main.rs", "src/lib.rs",
76
+ "main.go", "cmd/main.go",
77
+ "Sources/*/main.swift",
78
+ ]
79
+ for c in candidates:
80
+ matches = list(root.glob(c))
81
+ entries.extend(str(m.relative_to(root)) for m in matches)
82
+ return entries
83
+
84
+
85
+ def read_manifest_deps(root: Path) -> dict[str, list[str]]:
86
+ deps = {}
87
+ pkg = root / "package.json"
88
+ if pkg.exists():
89
+ import json
90
+ try:
91
+ data = json.loads(pkg.read_text())
92
+ deps["dependencies"] = list(data.get("dependencies", {}).keys())
93
+ deps["devDependencies"] = list(data.get("devDependencies", {}).keys())
94
+ except Exception:
95
+ pass
96
+
97
+ pyproject = root / "pyproject.toml"
98
+ if pyproject.exists():
99
+ text = pyproject.read_text()
100
+ matches = re.findall(r'^\s*"([a-zA-Z0-9_\-]+)[>=<!\[]', text, re.MULTILINE)
101
+ deps["python"] = matches
102
+
103
+ cargo = root / "Cargo.toml"
104
+ if cargo.exists():
105
+ text = cargo.read_text()
106
+ matches = re.findall(r'^([a-zA-Z0-9_\-]+)\s*=', text, re.MULTILINE)
107
+ deps["rust"] = [m for m in matches if m not in ("package", "lib", "bin", "dependencies", "features")]
108
+
109
+ return deps
pcp/escalations.py ADDED
@@ -0,0 +1,193 @@
1
+ """Escalation ledger + staleness watchdog.
2
+
3
+ Closes the "Escalation Failure" mode (a human never actually sees the
4
+ escalation) — PCP has lived this exact incident once: a slack-notify SSL
5
+ failure silently fell back to log-only and a security STOP sat unread for
6
+ 8 days. Recording an escalation is not the same thing as a human seeing it.
7
+
8
+ v2 (2026-07-17, PagerDuty/incident.io reference patterns):
9
+ - ACK and RESOLVE are separate states with separate timers. "Acknowledged"
10
+ (a human saw it — `pcp escalations --ack module/criterion`) is distinct
11
+ from "resolved" (the criterion actually left pending). An acked-but-stalled
12
+ escalation re-screams after 2x the threshold — a glance at Slack must not
13
+ silence the watchdog permanently.
14
+ - Stakes-scaled timeout: a module that other modules depend on blocks future
15
+ waves — its escalations go stale at HALF the normal threshold
16
+ (incident.io's SLO-derived-timeout pattern, deterministic here).
17
+ - MTTA: median time-to-acknowledge across acked entries, surfaced in
18
+ provenance — the feedback loop proving the ledger is actually watched.
19
+ - Failure category (memory/planning/action/system, from the AgentDebug
20
+ taxonomy arXiv:2509.25370) recorded per escalation, keyword-classified
21
+ deterministically from the block findings — sharpens human routing.
22
+
23
+ `.pcp/escalations.yaml` is a plain append list (operational record, not
24
+ hash-chained — same posture as prune_log.yaml). Resolution stays the
25
+ deterministic proxy: criterion no longer pending in acceptance.yaml.
26
+ """
27
+
28
+ import os
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ ESCALATIONS_FILE = "escalations.yaml"
35
+ DEFAULT_STALE_HOURS = 24
36
+ _TS_FMT = "%Y-%m-%dT%H:%M:%SZ"
37
+
38
+
39
+ def _stale_hours() -> float:
40
+ return float(os.environ.get("PCP_ESCALATION_STALE_HOURS", str(DEFAULT_STALE_HOURS)))
41
+
42
+
43
+ def classify_failure(findings: list[str]) -> str:
44
+ """Deterministic keyword classification into the AgentDebug taxonomy —
45
+ advisory routing signal, not ground truth."""
46
+ text = " ".join(findings).lower()
47
+ if "timeout" in text or "killed" in text or "exited with" in text or "errored" in text:
48
+ return "system"
49
+ if "test suite" in text or "lint" in text or "sast" in text or "scope guard" in text:
50
+ return "action"
51
+ if "alignment" in text or "architect" in text or "regression" in text or "coverage" in text:
52
+ return "planning"
53
+ if "context" in text or "session" in text:
54
+ return "memory"
55
+ return "uncategorized"
56
+
57
+
58
+ def record(pcp_dir: Path, module: str, criterion_id: str, route: str = "human",
59
+ findings: list[str] | None = None) -> None:
60
+ """Append one escalation entry. Never raises — an escalation must not be
61
+ lost because the ledger write failed, the console/notify path still runs."""
62
+ path = pcp_dir / ESCALATIONS_FILE
63
+ entry = {
64
+ "module": module,
65
+ "criterion_id": criterion_id,
66
+ "route": route,
67
+ "category": classify_failure(findings or []),
68
+ "timestamp": datetime.now(timezone.utc).strftime(_TS_FMT),
69
+ "acknowledged_at": None,
70
+ "findings_count": len(findings or []),
71
+ "findings_preview": (findings or [])[:3],
72
+ }
73
+ try:
74
+ entries = load(pcp_dir)
75
+ entries.append(entry)
76
+ path.write_text(yaml.dump({"escalations": entries}, default_flow_style=False))
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ def load(pcp_dir: Path) -> list[dict]:
82
+ path = pcp_dir / ESCALATIONS_FILE
83
+ if not path.exists():
84
+ return []
85
+ try:
86
+ data = yaml.safe_load(path.read_text()) or {}
87
+ except yaml.YAMLError:
88
+ return []
89
+ entries = data.get("escalations", [])
90
+ return entries if isinstance(entries, list) else []
91
+
92
+
93
+ def acknowledge(pcp_dir: Path, module: str, criterion_id: str) -> int:
94
+ """Stamp acknowledged_at on all un-acked entries for module/criterion.
95
+ Returns count acked."""
96
+ entries = load(pcp_dir)
97
+ now = datetime.now(timezone.utc).strftime(_TS_FMT)
98
+ count = 0
99
+ for e in entries:
100
+ if e.get("module") == module and e.get("criterion_id") == criterion_id and not e.get("acknowledged_at"):
101
+ e["acknowledged_at"] = now
102
+ count += 1
103
+ if count:
104
+ (pcp_dir / ESCALATIONS_FILE).write_text(yaml.dump({"escalations": entries}, default_flow_style=False))
105
+ return count
106
+
107
+
108
+ def _criterion_still_pending(pcp_dir: Path, module: str, criterion_id: str) -> bool:
109
+ af = pcp_dir / "strategy" / "modules" / module / "acceptance.yaml"
110
+ if not af.exists():
111
+ return False
112
+ try:
113
+ data = yaml.safe_load(af.read_text()) or {}
114
+ except yaml.YAMLError:
115
+ return False
116
+ for c in data.get("criteria", []):
117
+ if c.get("id") == criterion_id:
118
+ return c.get("status", "pending") != "complete"
119
+ return False
120
+
121
+
122
+ def _module_has_dependents(pcp_dir: Path, module: str) -> bool:
123
+ """True if any OTHER module declares this one as a dependency — its
124
+ failure blocks future waves, so its escalations get half the stale
125
+ window (stakes-scaled timeout)."""
126
+ modules_dir = pcp_dir / "strategy" / "modules"
127
+ if not modules_dir.exists():
128
+ return False
129
+ for spec_path in modules_dir.glob("*/spec.yaml"):
130
+ if spec_path.parent.name == module:
131
+ continue
132
+ try:
133
+ spec = yaml.safe_load(spec_path.read_text()) or {}
134
+ except yaml.YAMLError:
135
+ continue
136
+ if module in (spec.get("dependencies") or []):
137
+ return True
138
+ return False
139
+
140
+
141
+ def _parse_ts(value: str) -> datetime | None:
142
+ try:
143
+ return datetime.strptime(value, _TS_FMT).replace(tzinfo=timezone.utc)
144
+ except (ValueError, TypeError):
145
+ return None
146
+
147
+
148
+ def find_stale(pcp_dir: Path, now: datetime | None = None) -> list[dict]:
149
+ """Unresolved escalations needing a scream. Two states:
150
+ - state="unacked": no ack, older than the (stakes-scaled) threshold
151
+ - state="acked-stalled": acked, but criterion still pending past 2x
152
+ threshold since the ack — seen is not fixed."""
153
+ now = now or datetime.now(timezone.utc)
154
+ base_sec = _stale_hours() * 3600
155
+ stale = []
156
+ for e in load(pcp_dir):
157
+ ts = _parse_ts(e.get("timestamp", ""))
158
+ if ts is None:
159
+ continue
160
+ if not _criterion_still_pending(pcp_dir, e.get("module", ""), e.get("criterion_id", "")):
161
+ continue
162
+ threshold_sec = base_sec / 2 if _module_has_dependents(pcp_dir, e.get("module", "")) else base_sec
163
+ ack_ts = _parse_ts(e.get("acknowledged_at") or "")
164
+ e = dict(e)
165
+ if ack_ts is None:
166
+ age_sec = (now - ts).total_seconds()
167
+ if age_sec >= threshold_sec:
168
+ e["state"] = "unacked"
169
+ e["age_hours"] = round(age_sec / 3600, 1)
170
+ stale.append(e)
171
+ else:
172
+ since_ack = (now - ack_ts).total_seconds()
173
+ if since_ack >= 2 * threshold_sec:
174
+ e["state"] = "acked-stalled"
175
+ e["age_hours"] = round(since_ack / 3600, 1)
176
+ stale.append(e)
177
+ return stale
178
+
179
+
180
+ def mtta_hours(pcp_dir: Path) -> float | None:
181
+ """Median time-to-acknowledge in hours across acked entries — the
182
+ 'is anyone actually watching this ledger' metric (PagerDuty Escalation
183
+ Policy Insights pattern). None when nothing has ever been acked."""
184
+ deltas = []
185
+ for e in load(pcp_dir):
186
+ ts, ack = _parse_ts(e.get("timestamp", "")), _parse_ts(e.get("acknowledged_at") or "")
187
+ if ts and ack:
188
+ deltas.append((ack - ts).total_seconds() / 3600)
189
+ if not deltas:
190
+ return None
191
+ deltas.sort()
192
+ mid = len(deltas) // 2
193
+ return round(deltas[mid] if len(deltas) % 2 else (deltas[mid - 1] + deltas[mid]) / 2, 2)
pcp/evidence.py ADDED
@@ -0,0 +1,30 @@
1
+ """Full raw QA-proof storage.
2
+
3
+ telemetry.jsonl records a verdict (pass/block/skip/etc) plus a truncated
4
+ error summary — enough to see WHAT happened, not enough to independently
5
+ verify it happened the way claimed. This module stores the untruncated raw
6
+ artifact (full test output, full lint/SAST finding list, full architect-
7
+ review/gate judge response) under .pcp/evidence/<module>/<criterion_id>/
8
+ attempt_<n>/<check>.txt, and telemetry records the path instead of
9
+ embedding truncated text — proof, not just a verdict.
10
+
11
+ Always stores, on pass as well as block — "nothing to see, it passed" is
12
+ exactly the case proof is missing for today (a PASS currently records
13
+ nothing beyond the word "pass").
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+
19
+ def _evidence_dir(pcp_dir: Path, module: str, criterion_id: str | None, attempt: int) -> Path:
20
+ return Path(pcp_dir) / "evidence" / (module or "?") / (criterion_id or "?") / f"attempt_{attempt}"
21
+
22
+
23
+ def store(pcp_dir: Path, module: str, criterion_id: str | None, attempt: int, check: str, content: str) -> str:
24
+ """Writes raw content, returns its path relative to pcp_dir (so telemetry
25
+ can reference it without duplicating the content inline)."""
26
+ directory = _evidence_dir(pcp_dir, module, criterion_id, attempt)
27
+ directory.mkdir(parents=True, exist_ok=True)
28
+ path = directory / f"{check}.txt"
29
+ path.write_text("" if content is None else str(content))
30
+ return str(path.relative_to(pcp_dir))
pcp/evidence_chain.py ADDED
@@ -0,0 +1,56 @@
1
+ """Hash-chaining for append-only evidence logs (telemetry.jsonl,
2
+ decision_log.jsonl, bypass_log.yaml).
3
+
4
+ Each record's own hash covers its content plus the previous record's hash,
5
+ so retroactively editing an earlier entry breaks every hash after it. This
6
+ is tamper-EVIDENCE, not tamper-prevention: nothing stops someone editing the
7
+ file directly, but doing so without also recomputing every downstream hash
8
+ is now detectable by `verify_chain()` — and recomputing every downstream
9
+ hash to hide a change is a much louder, more deliberate act than quietly
10
+ editing one line, which is the actual threat model plain JSON-lines-append
11
+ had no defense against at all.
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+
17
+
18
+ def _canonical_hash(fields: dict) -> str:
19
+ return hashlib.sha256(json.dumps(fields, sort_keys=True, default=str).encode()).hexdigest()
20
+
21
+
22
+ def chain_entry(prev_hash: str | None, fields: dict) -> dict:
23
+ """Returns fields with prev_hash/entry_hash added. entry_hash covers the
24
+ full entry INCLUDING prev_hash, so it's a real link, not two independent
25
+ hashes that happen to sit in the same record."""
26
+ entry = {**fields, "prev_hash": prev_hash or "genesis"}
27
+ entry["entry_hash"] = _canonical_hash(entry)
28
+ return entry
29
+
30
+
31
+ def verify_chain(records: list[dict]) -> list[dict]:
32
+ """Returns a list of break descriptions — empty means the chain is
33
+ intact. Checks, per record: (1) its own entry_hash matches a fresh
34
+ recompute of its content, (2) its prev_hash matches the actual previous
35
+ record's entry_hash (catches reordering/deletion, not just edits)."""
36
+ breaks = []
37
+ prev_hash = None
38
+ for i, r in enumerate(records):
39
+ claimed_hash = r.get("entry_hash")
40
+ claimed_prev = r.get("prev_hash")
41
+ content = {k: v for k, v in r.items() if k != "entry_hash"}
42
+ expected_hash = _canonical_hash(content)
43
+ expected_prev = prev_hash or "genesis"
44
+
45
+ if claimed_prev != expected_prev:
46
+ breaks.append({
47
+ "index": i, "issue": "prev_hash mismatch (reordered or deleted entry)",
48
+ "expected": expected_prev, "found": claimed_prev,
49
+ })
50
+ if claimed_hash != expected_hash:
51
+ breaks.append({
52
+ "index": i, "issue": "entry_hash mismatch (content altered after the fact)",
53
+ "expected": expected_hash, "found": claimed_hash,
54
+ })
55
+ prev_hash = claimed_hash
56
+ return breaks
pcp/impact.py ADDED
@@ -0,0 +1,164 @@
1
+ """Impacted-module test selection.
2
+
3
+ 2026-07-21 finding: qa.py's test-suite gate ran the FULL project-wide suite
4
+ on every build attempt (up to 3x/criterion), deliberately never scoped --
5
+ real cost as a project's suite grows, and the thing that turned a real
6
+ 2026-07-21 Project O incident (a squatted DB port) into an
7
+ undiagnosable "timed out" for hours. Ganesh's direction: full-suite runs
8
+ should be rare, and scoping should reuse PCP's own module dependency graph
9
+ (coupling.py's build_dependency_graph, the same graph validate-strategy
10
+ already trusts for coupling_score) rather than a bespoke file-level AST
11
+ import analyzer -- one graph, one source of truth, not two competing ones.
12
+
13
+ Two-stage, both deterministic, no ML:
14
+ 1. Module-level blast radius: which module(s) does the changed file belong
15
+ to (via each module's acceptance.yaml `target` fields), then every
16
+ module that transitively DEPENDS ON those modules (nx.ancestors on the
17
+ same graph coupling.py builds -- edge A->B means "A depends on B", so a
18
+ change to B can only break A's callers if A is broken, not the reverse;
19
+ ancestors of B are exactly "everything that could be affected if B
20
+ changes").
21
+ 2. File-level: within the blast-radius modules, resolve their declared
22
+ `target` files to candidate test files via common naming conventions
23
+ (test_<stem>.py, near the target or under tests/). This layer is
24
+ inherently a heuristic -- PCP doesn't mandate a test-file-location
25
+ convention -- so it degrades safely: any step that can't confidently
26
+ narrow returns None, and the caller MUST fall back to the full suite
27
+ rather than silently running zero tests. Never a hard requirement.
28
+ """
29
+
30
+ from pathlib import Path
31
+
32
+ import networkx as nx
33
+
34
+ from pcp.coupling import build_dependency_graph
35
+ from pcp.schema.validator import load_yaml
36
+
37
+
38
+ def _load_modules_for_impact(pcp_dir: Path) -> dict[str, dict]:
39
+ modules_dir = pcp_dir / "strategy" / "modules"
40
+ modules: dict[str, dict] = {}
41
+ if not modules_dir.exists():
42
+ return modules
43
+ for spec_path in sorted(modules_dir.glob("*/spec.yaml")):
44
+ spec = load_yaml(spec_path) or {}
45
+ if not spec.get("deprecated"):
46
+ modules[spec_path.parent.name] = spec
47
+ return modules
48
+
49
+
50
+ def _module_target_files(pcp_dir: Path, module_name: str) -> set[str]:
51
+ """Files this module's acceptance criteria declare as `target`."""
52
+ acc_path = pcp_dir / "strategy" / "modules" / module_name / "acceptance.yaml"
53
+ if not acc_path.exists():
54
+ return set()
55
+ data = load_yaml(acc_path) or {}
56
+ return {c["target"] for c in data.get("criteria", []) if c.get("target")}
57
+
58
+
59
+ def _name_variants(module_name: str) -> set[str]:
60
+ """`web-server` also appears on disk as `web_server`."""
61
+ return {module_name, module_name.replace("-", "_")}
62
+
63
+
64
+ def _owns_by_path_convention(module_name: str, changed_files: list[str]) -> bool:
65
+ """Does a changed file sit under a directory named for this module?
66
+
67
+ Attribution used to rely solely on criteria declaring `target`. Measured
68
+ 2026-07-27 on Project O: only 51 of 382 criteria declare one, so
69
+ attribution returned an empty set for real changed files, scoping resolved
70
+ to None, and the gate fell back to the full 1,098-test suite on EVERY
71
+ criterion attempt -- roughly 7m46s each, about 13 hours of pure test time
72
+ across the remaining work, re-running the same tests every time.
73
+
74
+ Path convention covers what declarations do not: 18 of that project's 27
75
+ modules have a matching directory. Deterministic and documented, not a
76
+ guess -- a path SEGMENT must equal the module name (or its underscored
77
+ form), so `web-server` matches `src/app/web_server/routes.py` and never
78
+ matches `src/web_server_helpers.py`.
79
+ """
80
+ variants = _name_variants(module_name)
81
+ for f in changed_files:
82
+ if variants & set(Path(f).parts):
83
+ return True
84
+ return False
85
+
86
+
87
+ def changed_files_to_modules(pcp_dir: Path, modules: dict[str, dict], changed_files: list[str]) -> set[str]:
88
+ """Which module(s) own at least one of changed_files.
89
+
90
+ Two signals, both deterministic: a criterion's declared `target`, and the
91
+ directory convention above. Declared targets alone left this inert on real
92
+ projects (see _owns_by_path_convention)."""
93
+ changed = set(changed_files)
94
+ return {
95
+ name for name in modules
96
+ if (_module_target_files(pcp_dir, name) & changed)
97
+ or _owns_by_path_convention(name, changed_files)
98
+ }
99
+
100
+
101
+ def blast_radius_modules(modules: dict[str, dict], changed_module_names: set[str]) -> set[str]:
102
+ """changed_module_names plus every module that transitively depends on
103
+ any of them -- reuses coupling.py's own graph, not a second one."""
104
+ G = build_dependency_graph(modules)
105
+ radius = set(changed_module_names)
106
+ for m in changed_module_names:
107
+ if m in G:
108
+ radius |= nx.ancestors(G, m)
109
+ return radius
110
+
111
+
112
+ def blast_radius_test_paths(pcp_dir: Path, project_root: Path, changed_files: list[str]) -> list[str] | None:
113
+ """Returns test file paths (relative to project_root) scoped to the
114
+ modules impacted by changed_files, or None if scoping can't be
115
+ determined with confidence -- caller must fall back to the full suite,
116
+ never silently run zero tests."""
117
+ modules = _load_modules_for_impact(pcp_dir)
118
+ if not modules:
119
+ return None
120
+
121
+ changed_modules = changed_files_to_modules(pcp_dir, modules, changed_files)
122
+ if not changed_modules:
123
+ return None # can't attribute the change to a declared module -- don't guess
124
+
125
+ radius = blast_radius_modules(modules, changed_modules)
126
+
127
+ candidates: set[Path] = set()
128
+ for name in radius:
129
+ for target in _module_target_files(pcp_dir, name):
130
+ p = Path(target)
131
+ stem = p.stem
132
+ parent = p.parent
133
+ for cand in (
134
+ project_root / "tests" / f"test_{stem}.py",
135
+ project_root / parent / f"test_{stem}.py",
136
+ project_root / parent / "tests" / f"test_{stem}.py",
137
+ ):
138
+ if cand.exists():
139
+ candidates.add(cand)
140
+ if stem.startswith("test_") and (project_root / target).exists():
141
+ candidates.add(project_root / target) # the agent wrote the test as the "target" itself
142
+
143
+ # Per-module test DIRECTORIES (`tests/web_server/`) are how real projects
144
+ # actually lay this out; the file patterns above only find
145
+ # `tests/test_<stem>.py`. Without this the function returned None on
146
+ # Project O despite 18 of 27 modules having exactly such a directory.
147
+ for name in radius:
148
+ for variant in _name_variants(name):
149
+ d = project_root / "tests" / variant
150
+ if d.is_dir():
151
+ candidates.add(d)
152
+
153
+ if not candidates:
154
+ return None # narrowed to real modules but found no matching test files -- fall back, don't run zero
155
+
156
+ # Modularity drop-tests always run, whatever the radius. They are the
157
+ # module-boundary guarantee -- "every module is a guest, it can leave
158
+ # without drama" -- so a change that quietly couples two modules must fail
159
+ # HERE, at the criterion, not survive until the wave merge.
160
+ for d in (project_root / "tests" / "modularity", project_root / "tests" / "test_modularity"):
161
+ if d.is_dir():
162
+ candidates.add(d)
163
+
164
+ return sorted(str(c.relative_to(project_root)) for c in candidates)