program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/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
|
pcp/discovery/scanner.py
ADDED
|
@@ -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)
|