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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
@@ -0,0 +1,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
+ """