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/install_approvals.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Install-only fast-path approval log.
|
|
2
|
+
|
|
3
|
+
A human confirms a priorart direct match before `pcp build` skips the full
|
|
4
|
+
TDD/architect-review/LLM-gate cycle for a criterion (or whole module) and
|
|
5
|
+
just installs a dependency. Hash-chained like bypass_log.yaml/telemetry.jsonl
|
|
6
|
+
-- see evidence_chain.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from pcp.evidence_chain import chain_entry
|
|
15
|
+
|
|
16
|
+
LOG_NAME = "install_approvals.yaml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def log_install_approval(
|
|
20
|
+
pcp_dir: Path, *, module: str, criterion_id: str | None,
|
|
21
|
+
candidate: str, install_command: str, decision: str, actor: str = "human",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""decision: 'confirm' or 'reject'. criterion_id=None means a
|
|
24
|
+
module-level (whole-module) approval, not a single criterion."""
|
|
25
|
+
log_path = pcp_dir / LOG_NAME
|
|
26
|
+
existing = []
|
|
27
|
+
if log_path.exists():
|
|
28
|
+
data = yaml.safe_load(log_path.read_text()) or {}
|
|
29
|
+
existing = data.get("approvals", [])
|
|
30
|
+
|
|
31
|
+
prev_hash = existing[-1].get("entry_hash") if existing else None
|
|
32
|
+
fields = {
|
|
33
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
34
|
+
"actor": actor,
|
|
35
|
+
"module": module,
|
|
36
|
+
"criterion_id": criterion_id,
|
|
37
|
+
"candidate": candidate,
|
|
38
|
+
"install_command": install_command,
|
|
39
|
+
"decision": decision,
|
|
40
|
+
}
|
|
41
|
+
existing.append(chain_entry(prev_hash, fields))
|
|
42
|
+
|
|
43
|
+
with open(log_path, "w") as f:
|
|
44
|
+
yaml.dump({"approvals": existing}, f, default_flow_style=False)
|
pcp/integrity_audit.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Integrity Auditor -- generalizes coverage_audit.py's statistical-drift
|
|
2
|
+
pattern across telemetry/evidence signals PCP already records but never
|
|
3
|
+
analyzed this way: criteria completing suspiciously fast relative to their
|
|
4
|
+
declared logic_tier, a module with an outlier concentration of coerced-
|
|
5
|
+
placeholder flags vs. project average, the same gate finding recurring
|
|
6
|
+
across many criteria without genuinely resolving, and evidence files
|
|
7
|
+
suspiciously uniform/templated across criteria.
|
|
8
|
+
|
|
9
|
+
Deterministic-only in this version -- no LLM call. Retrospective by nature
|
|
10
|
+
(reads already-complete criteria): it cannot correct what's already built,
|
|
11
|
+
only flag for human review, the same advisory posture every other audit
|
|
12
|
+
pass in this codebase (coverage_audit, audit.py) already has. Runs at wave
|
|
13
|
+
boundaries, not per-criterion -- the value is seeing patterns across many
|
|
14
|
+
completed criteria that no single-criterion CTRL check can see by design.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
# Below this wall-clock duration, a criterion declaring a rung this
|
|
24
|
+
# demanding (real ML/RAG/cache work expected) finishing implies either a
|
|
25
|
+
# trivial implementation or a rubber-stamped gate -- worth a human glance,
|
|
26
|
+
# not proof of either.
|
|
27
|
+
FAST_COMPLETION_MS_THRESHOLD = 45_000
|
|
28
|
+
FAST_COMPLETION_MIN_TIER = 4
|
|
29
|
+
|
|
30
|
+
# A module whose placeholder-flag rate is this many times the project
|
|
31
|
+
# average reads as a systemic pattern, not noise from one lazy criterion.
|
|
32
|
+
PLACEHOLDER_OUTLIER_RATIO = 2.0
|
|
33
|
+
_PLACEHOLDER_CHECKS = frozenset({"design-justification", "build-vs-buy-justification", "customization"})
|
|
34
|
+
|
|
35
|
+
# A finding (or identical evidence) recurring across at least this many
|
|
36
|
+
# distinct criteria without ever landing a "pass" afterward reads as a
|
|
37
|
+
# stuck pattern, not a one-off.
|
|
38
|
+
RECURRING_MIN_CRITERIA = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _module_acceptance(pcp_dir: Path) -> dict:
|
|
42
|
+
modules_dir = pcp_dir / "strategy" / "modules"
|
|
43
|
+
out = {}
|
|
44
|
+
if not modules_dir.is_dir():
|
|
45
|
+
return out
|
|
46
|
+
for mod_dir in modules_dir.iterdir():
|
|
47
|
+
acc_path = mod_dir / "acceptance.yaml"
|
|
48
|
+
if acc_path.is_file():
|
|
49
|
+
try:
|
|
50
|
+
out[mod_dir.name] = yaml.safe_load(acc_path.read_text()) or {}
|
|
51
|
+
except yaml.YAMLError:
|
|
52
|
+
continue
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _signal_fast_completions(records: list[dict], acceptance_by_module: dict) -> list[str]:
|
|
57
|
+
tier_by_crit = {}
|
|
58
|
+
for mod_name, acc in acceptance_by_module.items():
|
|
59
|
+
for c in acc.get("criteria", []) or []:
|
|
60
|
+
tier_by_crit[(mod_name, c.get("id"))] = c.get("logic_tier")
|
|
61
|
+
|
|
62
|
+
findings = []
|
|
63
|
+
for r in records:
|
|
64
|
+
if r.get("cycle") != "build" or not r.get("duration_ms"):
|
|
65
|
+
continue
|
|
66
|
+
key = (r.get("module"), r.get("criterion_id"))
|
|
67
|
+
tier = tier_by_crit.get(key)
|
|
68
|
+
if not isinstance(tier, int) or tier < FAST_COMPLETION_MIN_TIER:
|
|
69
|
+
continue
|
|
70
|
+
if r["duration_ms"] < FAST_COMPLETION_MS_THRESHOLD:
|
|
71
|
+
findings.append(
|
|
72
|
+
f"{key[0]}/{key[1]}: declares logic_tier={tier} but completed in "
|
|
73
|
+
f"{r['duration_ms'] / 1000:.1f}s -- suspiciously fast for a rung expecting "
|
|
74
|
+
"real implementation work, worth a human glance"
|
|
75
|
+
)
|
|
76
|
+
return findings
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _signal_placeholder_concentration(records: list[dict]) -> list[str]:
|
|
80
|
+
flagged_by_module = defaultdict(int)
|
|
81
|
+
total_by_module = defaultdict(int)
|
|
82
|
+
for r in records:
|
|
83
|
+
if r.get("cycle") != "qa" or r.get("check") not in _PLACEHOLDER_CHECKS:
|
|
84
|
+
continue
|
|
85
|
+
mod = r.get("module")
|
|
86
|
+
total_by_module[mod] += 1
|
|
87
|
+
if r.get("errors"):
|
|
88
|
+
flagged_by_module[mod] += 1
|
|
89
|
+
|
|
90
|
+
total_checks = sum(total_by_module.values())
|
|
91
|
+
if not total_checks:
|
|
92
|
+
return []
|
|
93
|
+
overall_rate = sum(flagged_by_module.values()) / total_checks
|
|
94
|
+
if overall_rate == 0:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
findings = []
|
|
98
|
+
for mod, total in total_by_module.items():
|
|
99
|
+
flagged = flagged_by_module[mod]
|
|
100
|
+
rate = flagged / total
|
|
101
|
+
if flagged >= 2 and rate >= overall_rate * PLACEHOLDER_OUTLIER_RATIO:
|
|
102
|
+
findings.append(
|
|
103
|
+
f"{mod}: placeholder-flag rate {rate:.0%} vs. project average {overall_rate:.0%} "
|
|
104
|
+
f"({flagged}/{total} checks flagged) -- outlier concentration, worth reviewing "
|
|
105
|
+
"this module's declarations specifically"
|
|
106
|
+
)
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _signal_recurring_findings(records: list[dict]) -> list[str]:
|
|
111
|
+
seen: dict = defaultdict(set)
|
|
112
|
+
for r in records:
|
|
113
|
+
if r.get("cycle") != "qa" or r.get("result") != "block":
|
|
114
|
+
continue
|
|
115
|
+
crit_key = (r.get("module"), r.get("criterion_id"))
|
|
116
|
+
for err in r.get("errors") or []:
|
|
117
|
+
sig = " ".join(err.split()[:8]).lower()
|
|
118
|
+
if sig:
|
|
119
|
+
seen[(r.get("check"), sig)].add(crit_key)
|
|
120
|
+
|
|
121
|
+
findings = []
|
|
122
|
+
for (check, sig), crits in seen.items():
|
|
123
|
+
if len(crits) >= RECURRING_MIN_CRITERIA:
|
|
124
|
+
examples = ", ".join(f"{m}/{c}" for m, c in sorted(crits, key=lambda t: (t[0] or "", t[1] or ""))[:5])
|
|
125
|
+
findings.append(
|
|
126
|
+
f"[{check}] finding recurring near-verbatim across {len(crits)} distinct criteria "
|
|
127
|
+
f"({examples}) -- \"{sig}...\" -- same gate keeps firing without genuinely resolving, "
|
|
128
|
+
"may need a fix upstream of any one criterion"
|
|
129
|
+
)
|
|
130
|
+
return findings
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _signal_uniform_evidence(pcp_dir: Path, records: list[dict]) -> list[str]:
|
|
134
|
+
by_check_hash: dict = defaultdict(set)
|
|
135
|
+
for r in records:
|
|
136
|
+
if r.get("cycle") != "qa" or not r.get("evidence_path"):
|
|
137
|
+
continue
|
|
138
|
+
path = pcp_dir / r["evidence_path"]
|
|
139
|
+
try:
|
|
140
|
+
content = path.read_text(errors="replace").strip()
|
|
141
|
+
except OSError:
|
|
142
|
+
continue
|
|
143
|
+
if not content:
|
|
144
|
+
continue
|
|
145
|
+
digest = hashlib.sha256(content.encode()).hexdigest()
|
|
146
|
+
by_check_hash[(r.get("check"), digest)].add((r.get("module"), r.get("criterion_id")))
|
|
147
|
+
|
|
148
|
+
findings = []
|
|
149
|
+
for (check, _digest), crits in by_check_hash.items():
|
|
150
|
+
if len(crits) >= RECURRING_MIN_CRITERIA:
|
|
151
|
+
examples = ", ".join(f"{m}/{c}" for m, c in sorted(crits, key=lambda t: (t[0] or "", t[1] or ""))[:5])
|
|
152
|
+
findings.append(
|
|
153
|
+
f"[{check}] identical evidence content across {len(crits)} distinct criteria "
|
|
154
|
+
f"({examples}) -- suspiciously uniform/templated, check whether these attempts "
|
|
155
|
+
"genuinely differ or one got copy-pasted"
|
|
156
|
+
)
|
|
157
|
+
return findings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def analyze(pcp_dir: Path) -> list[str]:
|
|
161
|
+
"""Deterministic-only pass. Returns findings for the caller to
|
|
162
|
+
print/record -- advisory, never blocks, never corrects an already-
|
|
163
|
+
complete criterion (retrospective by nature)."""
|
|
164
|
+
from pcp import telemetry
|
|
165
|
+
|
|
166
|
+
records = telemetry.load(pcp_dir)
|
|
167
|
+
if not records:
|
|
168
|
+
return []
|
|
169
|
+
acceptance_by_module = _module_acceptance(pcp_dir)
|
|
170
|
+
|
|
171
|
+
findings: list[str] = []
|
|
172
|
+
findings += _signal_fast_completions(records, acceptance_by_module)
|
|
173
|
+
findings += _signal_placeholder_concentration(records)
|
|
174
|
+
findings += _signal_recurring_findings(records)
|
|
175
|
+
findings += _signal_uniform_evidence(pcp_dir, records)
|
|
176
|
+
return findings
|
pcp/librarian.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Librarian -- deterministic, rung-4-shaped retrieval so a criterion's
|
|
2
|
+
builder doesn't independently re-explore the codebase for a pattern another
|
|
3
|
+
module already has. Per CLAUDE.md's own Logic-Tier ladder, this is scoped as
|
|
4
|
+
retrieval (keyword-overlap against existing top-level definitions), not a
|
|
5
|
+
conversational search agent -- a full semantic-search build wasn't earned
|
|
6
|
+
without first proving grep-level retrieval is insufficient.
|
|
7
|
+
|
|
8
|
+
Pure query/response, no correction: it never blocks or judges, only surfaces
|
|
9
|
+
possibly-related existing code for the agent to check before reusing.
|
|
10
|
+
Injected into the build prompt bounded by count and chars, same posture
|
|
11
|
+
decision_log.format_for_prompt already established.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_SKIP_DIRS = {"__pycache__", ".venv", "venv", "node_modules", ".git", ".pcp", "dist", "build"}
|
|
18
|
+
_SOURCE_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb", ".java"}
|
|
19
|
+
|
|
20
|
+
_DEF_PATTERN = re.compile(
|
|
21
|
+
r"^\s*(?:def|class|function|const|export\s+function|export\s+default\s+function|"
|
|
22
|
+
r"export\s+const|export\s+class)\s+([A-Za-z_][A-Za-z0-9_]*)",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _keywords(text: str) -> set[str]:
|
|
27
|
+
return {w.lower() for w in re.findall(r"[A-Za-z]{5,}", text or "")}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_related_definitions(project_root: Path, criterion: dict, max_results: int = 6) -> list[str]:
|
|
31
|
+
"""Keyword-overlap scan over existing function/class/const definitions
|
|
32
|
+
across the project's own source files. Returns up to max_results
|
|
33
|
+
"path:line: name" hints, ranked by keyword-hit count, highest first.
|
|
34
|
+
Deterministic, zero LLM cost -- a cheap complementary signal, not
|
|
35
|
+
semantic search."""
|
|
36
|
+
keywords = _keywords(criterion.get("description", "")) | _keywords(criterion.get("id", ""))
|
|
37
|
+
if not keywords or not project_root.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
hits: list[tuple[int, str]] = []
|
|
41
|
+
for path in project_root.rglob("*"):
|
|
42
|
+
if not path.is_file() or path.suffix not in _SOURCE_EXTS:
|
|
43
|
+
continue
|
|
44
|
+
if any(seg in _SKIP_DIRS for seg in path.parts):
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
lines = path.read_text(errors="replace").splitlines()
|
|
48
|
+
except OSError:
|
|
49
|
+
continue
|
|
50
|
+
for lineno, line in enumerate(lines, start=1):
|
|
51
|
+
m = _DEF_PATTERN.match(line)
|
|
52
|
+
if not m:
|
|
53
|
+
continue
|
|
54
|
+
name = m.group(1)
|
|
55
|
+
name_lower = name.lower()
|
|
56
|
+
name_words = _keywords(name.replace("_", " "))
|
|
57
|
+
score = len(keywords & name_words) + sum(1 for k in keywords if k in name_lower)
|
|
58
|
+
if score > 0:
|
|
59
|
+
rel = path.relative_to(project_root)
|
|
60
|
+
hits.append((score, f"{rel}:{lineno}: {name}"))
|
|
61
|
+
|
|
62
|
+
hits.sort(key=lambda t: -t[0])
|
|
63
|
+
seen: set[str] = set()
|
|
64
|
+
out: list[str] = []
|
|
65
|
+
for _score, hint in hits:
|
|
66
|
+
if hint in seen:
|
|
67
|
+
continue
|
|
68
|
+
seen.add(hint)
|
|
69
|
+
out.append(hint)
|
|
70
|
+
if len(out) >= max_results:
|
|
71
|
+
break
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_for_prompt(project_root: Path, criterion: dict, max_results: int = 6, max_chars: int = 1200) -> list[str]:
|
|
76
|
+
"""Bounded-count, bounded-char rendering for direct inclusion in the
|
|
77
|
+
build prompt -- same Token Discipline posture as decision_log's own
|
|
78
|
+
format_for_prompt."""
|
|
79
|
+
hints = find_related_definitions(project_root, criterion, max_results=max_results)
|
|
80
|
+
if not hints:
|
|
81
|
+
return []
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
budget = max_chars
|
|
84
|
+
for h in hints:
|
|
85
|
+
if budget - len(h) < 0:
|
|
86
|
+
break
|
|
87
|
+
lines.append(f"- {h}")
|
|
88
|
+
budget -= len(h)
|
|
89
|
+
return lines
|
pcp/llm/__init__.py
ADDED
|
File without changes
|
pcp/llm/client.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""LLM client — common dispatch layer over per-vendor harness implementations.
|
|
2
|
+
|
|
3
|
+
Token discipline is a hard constraint, same tier as modularity (see CLAUDE.md).
|
|
4
|
+
Every call site must pass an explicit `model` — judge/advisory calls route to
|
|
5
|
+
Haiku by default; PCP_MODEL env always wins if a human sets it. Usage/cost is
|
|
6
|
+
captured (where the harness exposes it) and logged to .pcp/token_ledger.yaml
|
|
7
|
+
so spend is visible the same way coverage_score and coupling_score are.
|
|
8
|
+
|
|
9
|
+
Repo split (2026-07-31): this file is the COMMON half — dispatch, retry-on-
|
|
10
|
+
bad-JSON, model routing constants, none of it caring which vendor answered.
|
|
11
|
+
The actual per-vendor implementations live in llm/harness/ (claude.py,
|
|
12
|
+
agy.py, ...) — see that package's docstring for the contract a new harness
|
|
13
|
+
implements. `_log_usage`/token-ledger writing lives in llm/ledger.py,
|
|
14
|
+
independent of both this file and any harness/*.py, specifically to avoid a
|
|
15
|
+
circular import between the dispatcher and the harnesses it dispatches to.
|
|
16
|
+
|
|
17
|
+
Everything here re-exports what harness/claude.py and harness/agy.py define,
|
|
18
|
+
so existing `from pcp.llm.client import _claude_bin` / `llm.call_with_images`
|
|
19
|
+
/ patch("pcp.llm.client.call_json_with_images") call sites and tests are
|
|
20
|
+
unaffected by the split — only tests that patch subprocess.run directly
|
|
21
|
+
needed updating, to the module that actually owns the subprocess call now
|
|
22
|
+
(llm.harness.claude / llm.harness.agy).
|
|
23
|
+
|
|
24
|
+
Scope, stated honestly: this file (and the harness/ split under it) covers
|
|
25
|
+
PCP's JUDGE/GENERATION calls only. It does NOT cover `pcp build`'s own
|
|
26
|
+
coding-agent loop -- the part that actually writes code (commands/build.py,
|
|
27
|
+
worktree-per-criterion, `--resume`-based retry) is a separate, deeper
|
|
28
|
+
subprocess integration this seam does not touch. Porting THAT to a
|
|
29
|
+
different harness (Codex, or promoting agy beyond its current verifier-
|
|
30
|
+
only role) is real, additional work, and for agy specifically also runs
|
|
31
|
+
past this repo's own CLAUDE.md scoping of agy to research/QA/analysis, not
|
|
32
|
+
code-writing -- a policy question, not just a technical one, worth a human
|
|
33
|
+
decision before extending it there.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from pcp.llm.harness.claude import (
|
|
42
|
+
_claude_bin, _timeout, _call_claude,
|
|
43
|
+
call_with_images, call_with_image, call_json_with_images, call_json_with_image,
|
|
44
|
+
_MEDIA_TYPES,
|
|
45
|
+
)
|
|
46
|
+
from pcp.llm.harness.agy import _agy_bin, _agy_timeout, _call_agy
|
|
47
|
+
from pcp.llm.ledger import _LEDGER_LOCK, _log_usage
|
|
48
|
+
|
|
49
|
+
JUDGE_MODEL = "haiku"
|
|
50
|
+
# Model-selection strategy (reviewed and approved 2026-07-17) -- same
|
|
51
|
+
# cheapest-tool-that-correctly-does-the-job philosophy as the Logic-Tier
|
|
52
|
+
# Selection ladder (CLAUDE.md), applied to PCP's own LLM call sites instead
|
|
53
|
+
# of to the projects PCP builds:
|
|
54
|
+
# Haiku -- bounded, structured judge calls (JUDGE_MODEL, unchanged)
|
|
55
|
+
# Sonnet -- pcp build's coding agent + kickoff/pm generation (BUILD_MODEL)
|
|
56
|
+
# Opus -- escalation only: 3rd/final build-criterion attempt, and
|
|
57
|
+
# wave-level architect-review (ESCALATION_MODEL) -- both have
|
|
58
|
+
# a materially higher blast radius than a per-criterion Haiku
|
|
59
|
+
# check, worth paying up for
|
|
60
|
+
# Fable 5 -- never a default anywhere in this file; PCP_BUILD_MODEL is
|
|
61
|
+
# the only path to it, a PM's explicit, deliberate override.
|
|
62
|
+
# Its always-on-thinking/minutes-long-turn profile conflicts
|
|
63
|
+
# with Token Discipline as a default for anything here.
|
|
64
|
+
BUILD_MODEL = "sonnet"
|
|
65
|
+
ESCALATION_MODEL = "opus"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Harness seam ────────────────────────────────────────────────────────
|
|
69
|
+
# call()/call_json() below route through a per-vendor implementation chosen
|
|
70
|
+
# by `harness` (default "claude", PCP_LLM_HARNESS env overrides). This is
|
|
71
|
+
# the plug point for a future harness: implement _call_<name>() in a new
|
|
72
|
+
# llm/harness/<name>.py with the same contract as _call_claude()/_call_agy()
|
|
73
|
+
# (returns text, or (text, meta) when return_meta=True; raises RuntimeError
|
|
74
|
+
# on a CLI-level failure), add it to SUPPORTED_HARNESSES and
|
|
75
|
+
# _HARNESS_IMPLS, done -- call_json()'s retry-on-bad-JSON logic and every
|
|
76
|
+
# judge/generation call site (kickoff/pm/gate/architect-review/build's
|
|
77
|
+
# _verify_block_findings) work unchanged, they never see which harness
|
|
78
|
+
# actually answered.
|
|
79
|
+
|
|
80
|
+
SUPPORTED_HARNESSES = ("claude", "agy")
|
|
81
|
+
_HARNESS_IMPLS = {"claude": _call_claude, "agy": _call_agy}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_harness(harness: str | None) -> str:
|
|
85
|
+
"""PCP_LLM_HARNESS env always wins, same override precedence PCP_MODEL
|
|
86
|
+
already has for models -- a human forcing a harness for debugging
|
|
87
|
+
shouldn't need to edit every call site."""
|
|
88
|
+
h = os.environ.get("PCP_LLM_HARNESS") or harness or "claude"
|
|
89
|
+
if h not in SUPPORTED_HARNESSES:
|
|
90
|
+
raise ValueError(f"Unknown harness '{h}' -- one of {SUPPORTED_HARNESSES}.")
|
|
91
|
+
return h
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def call(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
|
|
95
|
+
command: str = "llm.call", return_meta: bool = False,
|
|
96
|
+
harness: str | None = None) -> str | tuple[str, dict]:
|
|
97
|
+
"""Dispatches to the resolved harness's implementation. See the module
|
|
98
|
+
docstring for the seam's scope and contract. Existing call sites that
|
|
99
|
+
never pass `harness` are unaffected -- this resolves to "claude",
|
|
100
|
+
exactly the prior hardcoded behavior."""
|
|
101
|
+
impl = _HARNESS_IMPLS[_resolve_harness(harness)]
|
|
102
|
+
return impl(system, user, model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _json_retries() -> int:
|
|
106
|
+
return max(0, int(os.environ.get("PCP_LLM_JSON_RETRIES", "2")))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def call_json(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
|
|
110
|
+
command: str = "llm.call_json", return_meta: bool = False,
|
|
111
|
+
harness: str | None = None) -> Any:
|
|
112
|
+
"""Call the resolved harness, parse response as JSON, retrying a
|
|
113
|
+
malformed response. Harness-agnostic by construction -- it only calls
|
|
114
|
+
call() and parses text, so this same retry logic now covers every
|
|
115
|
+
harness in SUPPORTED_HARNESSES without duplicating it per-vendor (agy's
|
|
116
|
+
JSON-retry loop used to be a separate near-identical copy of this one;
|
|
117
|
+
unified 2026-07-31).
|
|
118
|
+
|
|
119
|
+
A response that is not parseable JSON is the single most retryable failure
|
|
120
|
+
an LLM call has: the model answered, it just answered in the wrong shape.
|
|
121
|
+
Asking again is the rung-1 response.
|
|
122
|
+
|
|
123
|
+
Without a retry, one transient `Extra data: line 15 column 1` propagated as
|
|
124
|
+
an exception, became a blocking gate finding, consumed a criterion attempt,
|
|
125
|
+
and after three of them the remedy PCP offered was
|
|
126
|
+
PCP_ALLOW_UNVERIFIED_GATES=1 -- turn the gate off. Reported from
|
|
127
|
+
Project O 2026-07-27, where it cost three attempts on one criterion
|
|
128
|
+
and where the same architect review had caught a real path-traversal
|
|
129
|
+
vulnerability an hour earlier. Offering "skip the check" as the cure for a
|
|
130
|
+
flaky check points at exactly the wrong lever.
|
|
131
|
+
|
|
132
|
+
Retries ONLY on a JSON parse failure. A RuntimeError from the CLI (rate
|
|
133
|
+
limit, timeout, not authenticated) is a different condition with its own
|
|
134
|
+
handling -- retrying that here would just multiply the wait.
|
|
135
|
+
"""
|
|
136
|
+
prompt = user + "\n\nRespond with valid JSON only. No markdown fences."
|
|
137
|
+
attempts = _json_retries() + 1
|
|
138
|
+
last_exc: Exception | None = None
|
|
139
|
+
|
|
140
|
+
for attempt in range(attempts):
|
|
141
|
+
out = call(
|
|
142
|
+
system, prompt, model=model, pcp_dir=pcp_dir, command=command,
|
|
143
|
+
return_meta=return_meta, harness=harness,
|
|
144
|
+
)
|
|
145
|
+
text, meta = out if return_meta else (out, None)
|
|
146
|
+
text = text.strip()
|
|
147
|
+
# Strip markdown fences if model adds them anyway
|
|
148
|
+
if text.startswith("```"):
|
|
149
|
+
lines = text.split("\n")
|
|
150
|
+
text = "\n".join(lines[1:-1])
|
|
151
|
+
try:
|
|
152
|
+
parsed = json.loads(text)
|
|
153
|
+
except json.JSONDecodeError as exc:
|
|
154
|
+
last_exc = exc
|
|
155
|
+
if attempt + 1 >= attempts:
|
|
156
|
+
break
|
|
157
|
+
# Say what went wrong -- a blind re-ask tends to reproduce the same
|
|
158
|
+
# malformed shape.
|
|
159
|
+
prompt = (
|
|
160
|
+
user
|
|
161
|
+
+ "\n\nYour previous response could not be parsed as JSON: "
|
|
162
|
+
+ f"{exc}. Respond with ONE valid JSON object and nothing else -- "
|
|
163
|
+
+ "no prose before or after it, no markdown fences."
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
return (parsed, meta) if return_meta else parsed
|
|
167
|
+
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{command}: response was not valid JSON after {attempts} attempt(s): {last_exc}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def call_json_agy(system: str, user: str, pcp_dir: Path | None = None,
|
|
174
|
+
command: str = "llm.call_json_agy") -> Any:
|
|
175
|
+
"""Backward-compatible convenience wrapper -- call_json(..., harness="agy").
|
|
176
|
+
Kept as a named function since Loop 3's cross-vendor verifier leg
|
|
177
|
+
(build.py's _verify_block_findings, proposed 2026-07-22, resumed
|
|
178
|
+
2026-07-31 -- see memory `project-cross-vendor-verifier-deferred-2026-07-22`)
|
|
179
|
+
already calls it by this name; existing callers/tests don't need to
|
|
180
|
+
change. Scope note: callers must keep cross-vendor use narrow
|
|
181
|
+
(CTRL-005/CTRL-006 BLOCK findings only, per the original proposal) --
|
|
182
|
+
this function does not enforce that scope itself, the caller does."""
|
|
183
|
+
return call_json(system, user, pcp_dir=pcp_dir, command=command, harness="agy")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The coding-agent-loop contract -- Stage 1 of the multi-harness plan
|
|
2
|
+
(see git tag `pre-multi-harness-extension` for the state this plan started
|
|
3
|
+
from, and the conversation it came out of for the full reasoning).
|
|
4
|
+
|
|
5
|
+
This is documentation given a type, not a running abstraction. `pcp
|
|
6
|
+
build`'s actual coding loop (commands/build.py's per-attempt subprocess
|
|
7
|
+
call, inside _build_one_criterion) still calls `claude -p` directly --
|
|
8
|
+
nothing here is wired into it yet, and nothing here changes its behavior.
|
|
9
|
+
What this file does is name, precisely, what that call currently does, so:
|
|
10
|
+
|
|
11
|
+
1. A second harness (Codex, or agy promoted beyond its current verifier-
|
|
12
|
+
only role) has a concrete target to implement against, once someone
|
|
13
|
+
actually builds it -- instead of reverse-engineering build.py's
|
|
14
|
+
~150-line inline subprocess dance from scratch.
|
|
15
|
+
2. tests/test_coding_agent_contract.py can mechanically check build.py's
|
|
16
|
+
own implementation still satisfies what's declared here -- a structural
|
|
17
|
+
guard against silent drift between "what the contract says" and "what
|
|
18
|
+
the one real implementation actually does", the same shape as
|
|
19
|
+
test_build_parallel.py's test_no_unregistered_pcp_runtime_writer.
|
|
20
|
+
|
|
21
|
+
Deliberately NOT done here (see the conversation for why): extracting
|
|
22
|
+
build.py's inline call into a _run_coding_agent_claude() that implements
|
|
23
|
+
this contract, the way llm/harness/claude.py's _call_claude() implements
|
|
24
|
+
client.py's judge-call contract. That extraction is Stage 3/4 of the plan,
|
|
25
|
+
and it waits for a second real implementation to validate the boundary
|
|
26
|
+
against -- building it speculatively, alone, against a guess is a bigger
|
|
27
|
+
risk than the drift this file exists to catch in the meantime.
|
|
28
|
+
|
|
29
|
+
Three policy points this contract makes explicit because they were each a
|
|
30
|
+
real, deliberate design decision (see build.py's own comments at the call
|
|
31
|
+
site) and are exactly the kind of thing a naive re-implementation would
|
|
32
|
+
get wrong by "simplifying":
|
|
33
|
+
|
|
34
|
+
- Attempt 1 opens a FRESH session. Attempt 2 RESUMES it (Token
|
|
35
|
+
Discipline -- avoid re-exploring the repo). Attempt 3 (escalation)
|
|
36
|
+
does NOT resume -- deliberately fresh, because contaminated retry
|
|
37
|
+
context measurably hurts (CCRM, arXiv:2605.08563, 7.1x baseline error
|
|
38
|
+
rate). This is a POLICY the caller decides (which attempt, therefore
|
|
39
|
+
fresh vs resume) -- the harness implementation just needs to support
|
|
40
|
+
both "open fresh under session_id" and "resume session_id", not decide
|
|
41
|
+
between them itself.
|
|
42
|
+
- Every attempt has an explicit wall-clock timeout AND an explicit
|
|
43
|
+
dollar budget ceiling, both passed in per-call, not read from the
|
|
44
|
+
harness's own defaults -- a stuck/looping agent must not run
|
|
45
|
+
unbounded just because it hasn't returned yet, and the ceiling is
|
|
46
|
+
PCP's own circuit breaker (PCP_MAX_BUILD_SESSIONS /
|
|
47
|
+
PCP_BUILD_AGENT_MAX_BUDGET_USD), not the harness's.
|
|
48
|
+
- The primitive's job ends at "did it run, what did it cost, what
|
|
49
|
+
session id came out of it." It does NOT report what changed --
|
|
50
|
+
build.py reads that back from git (_get_changed_files_since,
|
|
51
|
+
_get_working_diff) as a separate, already-harness-agnostic step. A
|
|
52
|
+
harness implementation that tried to self-report its own diff would
|
|
53
|
+
be trusting the agent's own claim about its own work, exactly the
|
|
54
|
+
kind of unverified self-report PCP's own gates exist to not trust
|
|
55
|
+
anywhere else.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Protocol
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CodingAgentRequest:
|
|
65
|
+
"""One attempt. `resume_session_id` is None for a fresh session (attempt
|
|
66
|
+
1 and attempt 3/escalation), set for a resumed one (attempt 2) -- the
|
|
67
|
+
CALLER decides fresh vs resume (see module docstring), this is just the
|
|
68
|
+
resulting instruction to the harness."""
|
|
69
|
+
prompt: str
|
|
70
|
+
cwd: Path
|
|
71
|
+
session_id: str
|
|
72
|
+
resume_session_id: str | None
|
|
73
|
+
model: str | None
|
|
74
|
+
timeout_sec: int
|
|
75
|
+
max_budget_usd: str | float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CodingAgentResult:
|
|
80
|
+
"""`ok=False` covers every failure the current build.py code already
|
|
81
|
+
distinguishes as a retryable attempt failure (timeout, non-zero exit,
|
|
82
|
+
is_error envelope) -- see _build_one_criterion's attempt loop, which
|
|
83
|
+
treats all three the same way (record feedback, continue to next
|
|
84
|
+
attempt). `changed_files`/`diff` are deliberately NOT fields here --
|
|
85
|
+
see module docstring's third policy point."""
|
|
86
|
+
ok: bool
|
|
87
|
+
error: str | None
|
|
88
|
+
session_id: str | None
|
|
89
|
+
model: str | None
|
|
90
|
+
usage: dict = field(default_factory=dict)
|
|
91
|
+
cost_usd: float | None = None
|
|
92
|
+
duration_ms: int | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CodingAgentHarness(Protocol):
|
|
96
|
+
"""Contract any coding-agent-loop implementation satisfies. See module
|
|
97
|
+
docstring -- build.py's own inline implementation (commands/build.py,
|
|
98
|
+
_build_one_criterion's attempt loop) is the one real implementation
|
|
99
|
+
today; it is NOT wired to this Protocol (no `class ClaudeCodingAgent`
|
|
100
|
+
exists yet), so this is checked by structural inspection
|
|
101
|
+
(tests/test_coding_agent_contract.py), not by isinstance/typing."""
|
|
102
|
+
|
|
103
|
+
def run(self, request: CodingAgentRequest) -> CodingAgentResult:
|
|
104
|
+
...
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Per-vendor harness implementations.
|
|
2
|
+
|
|
3
|
+
One file per harness (claude.py, agy.py, ...), each implementing the same
|
|
4
|
+
contract client.py's call()/call_json() dispatch against: a `_call_<name>`
|
|
5
|
+
function taking (system, user, model=None, pcp_dir=None, command=...,
|
|
6
|
+
return_meta=False) and returning text, or (text, meta) when return_meta is
|
|
7
|
+
True, raising RuntimeError on a CLI-level failure. See client.py's own
|
|
8
|
+
module docstring (SUPPORTED_HARNESSES section) for how a new harness plugs
|
|
9
|
+
in and what this seam does and does not cover -- it's the judge/generation
|
|
10
|
+
call path only, not `pcp build`'s own coding-agent loop (still Claude-Code-
|
|
11
|
+
specific, lives in commands/build.py, not abstracted here).
|
|
12
|
+
"""
|