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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/llm/harness/agy.py ADDED
@@ -0,0 +1,121 @@
1
+ """agy (Antigravity CLI / Google Gemini) harness.
2
+
3
+ Split out of client.py 2026-07-31 -- see llm/harness/__init__.py's
4
+ docstring for the shared contract. Added 2026-07-31 for Loop 3's
5
+ cross-vendor architect-review verifier leg (proposed 2026-07-22, parked,
6
+ resumed -- see memory `project-cross-vendor-verifier-deferred-2026-07-22`).
7
+ Currently the ONLY caller is build.py's _verify_block_findings, scoped
8
+ narrowly to CTRL-005/CTRL-006 -- this file itself doesn't enforce that
9
+ scope, the caller does.
10
+
11
+ Real usage data, corrected same day: `agy -p ... --output-format json`
12
+ (not the bare text mode this file originally used) returns a genuine
13
+ envelope with `conversation_id` (real session-resume support, verified
14
+ live against `--conversation <id>`) and a `usage` block with
15
+ input/output/thinking/cache_read/total token counts. There is still no
16
+ `cost_usd` -- agy exposes no pricing -- so that field stays an honest
17
+ None, not a fabricated number. But "agy has no usage data" was wrong; it
18
+ just wasn't being asked for it. Real token counts now feed
19
+ pcp.llm.ledger._log_usage like every other harness, and
20
+ PCP_AGY_MAX_TOKENS_PER_CALL is a real circuit breaker on top of real
21
+ numbers, not a guess.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ from pathlib import Path
28
+
29
+ from pcp.llm.ledger import _log_usage
30
+
31
+
32
+ def _agy_bin() -> str:
33
+ return os.environ.get("PCP_AGY_BIN", "agy")
34
+
35
+
36
+ def _agy_timeout() -> int:
37
+ return int(os.environ.get("PCP_AGY_TIMEOUT", "240"))
38
+
39
+
40
+ def _agy_effort() -> str:
41
+ """Default "low" -- Loop 3's only caller today is a verifier check, not
42
+ a task that benefits from deep reasoning, and effort is real cost
43
+ control agy exposes (confirmed: --effort low measurably drops
44
+ thinking_tokens). PCP_AGY_EFFORT overrides for a caller that needs more."""
45
+ return os.environ.get("PCP_AGY_EFFORT", "low")
46
+
47
+
48
+ def _agy_max_tokens_per_call() -> int:
49
+ """Per-call ceiling, same role --max-budget-usd plays for claude (a
50
+ live cap passed/checked per call, not a cumulative tracker) -- except
51
+ agy has no self-enforcing budget flag, so this is checked AFTER the
52
+ call returns, against real reported usage. Can't prevent the tokens
53
+ already spent on a call that blew past it, but does turn that call
54
+ into a raised, non-silent failure (fails the same way a timeout or
55
+ missing binary does) rather than a quietly-accepted result -- and
56
+ stops a caller from treating an oversized response as normal."""
57
+ return int(os.environ.get("PCP_AGY_MAX_TOKENS_PER_CALL", "200000"))
58
+
59
+
60
+ def _call_agy(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
61
+ command: str = "llm.call", return_meta: bool = False) -> str | tuple[str, dict]:
62
+ """Same contract as harness.claude._call_claude(). `model`, if given, is
63
+ passed through to agy's own `--model` flag. `--effort` is always passed
64
+ (see _agy_effort()) -- real cost control, not optional."""
65
+ prompt = f"{system}\n\n---\n\n{user}"
66
+ cwd = Path(pcp_dir).parent if pcp_dir else None
67
+ cmd = [
68
+ _agy_bin(), "-p", prompt, "--output-format", "json",
69
+ "--print-timeout", f"{_agy_timeout()}s", "--effort", _agy_effort(),
70
+ ]
71
+ if model:
72
+ cmd += ["--model", model]
73
+
74
+ try:
75
+ result = subprocess.run(
76
+ cmd, capture_output=True, text=True, timeout=_agy_timeout() + 30, cwd=cwd,
77
+ )
78
+ except FileNotFoundError:
79
+ raise RuntimeError(
80
+ f"agy CLI not found at '{_agy_bin()}'. Install Antigravity CLI, or unset "
81
+ "PCP_LLM_HARNESS/PCP_VERIFIER_CROSS_VENDOR to skip it."
82
+ )
83
+ except subprocess.TimeoutExpired:
84
+ raise RuntimeError(f"agy CLI timed out after {_agy_timeout()}s")
85
+
86
+ if result.returncode != 0:
87
+ raise RuntimeError(f"agy CLI exited {result.returncode}: {result.stderr.strip()}")
88
+
89
+ try:
90
+ envelope = json.loads(result.stdout)
91
+ except json.JSONDecodeError:
92
+ # Fallback: unexpected/older CLI output -- treat stdout as raw text,
93
+ # same posture harness.claude._call_claude() takes on the same failure.
94
+ text = result.stdout.strip()
95
+ return (text, {"model": model or "agy/default", "session_id": None, "usage": {}, "cost_usd": None, "duration_ms": None}) if return_meta else text
96
+
97
+ if envelope.get("status") and envelope.get("status") != "SUCCESS":
98
+ raise RuntimeError(f"agy CLI reported status={envelope.get('status')}: {envelope.get('response', '')}")
99
+
100
+ usage = envelope.get("usage", {}) or {}
101
+ total_tokens = usage.get("total_tokens", 0)
102
+ if total_tokens > _agy_max_tokens_per_call():
103
+ raise RuntimeError(
104
+ f"agy call used {total_tokens} tokens, over the {_agy_max_tokens_per_call()} "
105
+ "PCP_AGY_MAX_TOKENS_PER_CALL ceiling -- treated as a failed call, same as a "
106
+ "timeout, rather than silently accepted."
107
+ )
108
+
109
+ duration_ms = int(envelope.get("duration_seconds", 0) * 1000) if envelope.get("duration_seconds") else None
110
+ conversation_id = envelope.get("conversation_id")
111
+
112
+ _log_usage(pcp_dir, command, model or "agy/default", conversation_id, usage, None)
113
+
114
+ text = (envelope.get("response") or "").strip()
115
+ if not return_meta:
116
+ return text
117
+ meta = {
118
+ "model": model or "agy/default", "session_id": conversation_id,
119
+ "usage": usage, "cost_usd": None, "duration_ms": duration_ms,
120
+ }
121
+ return text, meta
@@ -0,0 +1,180 @@
1
+ """Antigravity CLI (agy) coding-agent-loop harness -- implements
2
+ CodingAgentHarness (llm/coding_agent_contract.py).
3
+
4
+ Provenance: the first draft of this file was written BY agy itself
5
+ (2026-08-01), given the contract and agy's own verified real CLI behavior
6
+ as input -- the same reasoning applied to "let Codex verify its own
7
+ contract instead of guessing" throughout the multi-harness conversation
8
+ (tag `pre-multi-harness-extension`) applies to agy here too. Reviewed and
9
+ lightly corrected before being accepted, same as any other AI-authored
10
+ change in this codebase -- not applied blind.
11
+
12
+ One correction from the original draft worth recording: it hardcoded
13
+ `--effort low` for every call, copying that choice from harness/agy.py's
14
+ verifier leg (a yes/no judgment call, where low effort is a deliberate,
15
+ measured cost control) without noticing that actual code generation is a
16
+ different kind of task -- forcing low reasoning effort on real coding work
17
+ isn't a cost optimization there, it's a quality regression. Fixed to leave
18
+ effort unset by default (agy's own default reasoning depth), configurable
19
+ via PCP_AGY_CODING_EFFORT for a caller that wants to override it either
20
+ direction.
21
+
22
+ Honest limitations, stated rather than papered over:
23
+ 1. No pre-enforced budget ceiling. agy has no `--max-budget-usd`
24
+ equivalent -- request.max_budget_usd cannot be pre-enforced at the CLI
25
+ level, only checked after the fact against real reported tokens
26
+ (see PCP_AGY_MAX_TOKENS_PER_CALL below, default 500,000/call).
27
+ 2. cost_usd is always None. agy reports real token usage but no pricing
28
+ data -- never fabricated here.
29
+ 3. request.session_id (the caller-chosen id for a FRESH session) is
30
+ advisory only, not actually usable. Unlike Claude Code's
31
+ `--session-id <id>` (open a NEW session under a caller-picked name),
32
+ agy always self-assigns its own conversation_id on a fresh call --
33
+ there is no flag to open one under a name PCP picked. The REAL id
34
+ PCP must track going forward is whatever comes back in the result,
35
+ not what it requested. This is a genuine harness capability
36
+ difference (see the multi-harness conversation's "accept divergence
37
+ at the implementation layer" conclusion), not a bug to paper over.
38
+
39
+ Not wired into `pcp build` -- Stage 3/4 of the multi-harness plan still
40
+ require a deliberate decision to actually route criteria through this
41
+ harness instead of Claude Code. This file existing does not change
42
+ `pcp build`'s behavior.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import subprocess
50
+ import time
51
+ from pathlib import Path
52
+
53
+ from pcp.llm.coding_agent_contract import CodingAgentRequest, CodingAgentResult
54
+
55
+ DEFAULT_MAX_TOKENS_PER_CALL = 500_000
56
+
57
+
58
+ def _agy_coding_effort() -> str | None:
59
+ """None by default -- let agy use its own default reasoning depth for
60
+ real code-writing, unlike the verifier leg's deliberate --effort low.
61
+ PCP_AGY_CODING_EFFORT overrides either direction."""
62
+ return os.environ.get("PCP_AGY_CODING_EFFORT")
63
+
64
+
65
+ def _agy_coding_max_tokens_per_call() -> int:
66
+ env_limit = os.environ.get("PCP_AGY_MAX_TOKENS_PER_CALL")
67
+ return int(env_limit) if env_limit else DEFAULT_MAX_TOKENS_PER_CALL
68
+
69
+
70
+ class AgyCodingAgentHarness:
71
+ """Coding agent harness implementation wrapping the Antigravity `agy`
72
+ CLI. See module docstring for what's honestly not supported."""
73
+
74
+ def __init__(self, max_tokens_limit: int | None = None) -> None:
75
+ self.max_tokens_limit = max_tokens_limit if max_tokens_limit is not None else _agy_coding_max_tokens_per_call()
76
+
77
+ def run(self, request: CodingAgentRequest) -> CodingAgentResult:
78
+ """Run one coding-task attempt using the `agy` CLI in unattended
79
+ file-editing mode against request.cwd."""
80
+ cmd: list[str] = [
81
+ "agy", "-p", request.prompt,
82
+ "--output-format", "json",
83
+ "--print-timeout", f"{request.timeout_sec}s",
84
+ "--dangerously-skip-permissions",
85
+ "--mode", "accept-edits",
86
+ "--sandbox",
87
+ "--add-dir", str(request.cwd),
88
+ ]
89
+ effort = _agy_coding_effort()
90
+ if effort:
91
+ cmd += ["--effort", effort]
92
+
93
+ if request.resume_session_id:
94
+ cmd += ["--conversation", request.resume_session_id]
95
+ # No else branch: agy has no flag to open a fresh session under a
96
+ # caller-chosen id (see module docstring, limitation 3) -- a fresh
97
+ # call just starts a new conversation and self-assigns its own id.
98
+
99
+ if request.model:
100
+ cmd += ["--model", str(request.model)]
101
+
102
+ start_time = time.monotonic()
103
+
104
+ try:
105
+ res = subprocess.run(
106
+ cmd, cwd=request.cwd, capture_output=True, text=True, timeout=request.timeout_sec,
107
+ )
108
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
109
+ except subprocess.TimeoutExpired:
110
+ return CodingAgentResult(
111
+ ok=False, error=f"Execution timed out after {request.timeout_sec} seconds",
112
+ session_id=request.resume_session_id or request.session_id, model=request.model,
113
+ duration_ms=int((time.monotonic() - start_time) * 1000),
114
+ )
115
+ except FileNotFoundError:
116
+ return CodingAgentResult(
117
+ ok=False, error="Executable 'agy' CLI was not found in PATH",
118
+ session_id=request.resume_session_id or request.session_id, model=request.model,
119
+ duration_ms=int((time.monotonic() - start_time) * 1000),
120
+ )
121
+ except Exception as e:
122
+ return CodingAgentResult(
123
+ ok=False, error=f"Subprocess execution failed: {e}",
124
+ session_id=request.resume_session_id or request.session_id, model=request.model,
125
+ duration_ms=int((time.monotonic() - start_time) * 1000),
126
+ )
127
+
128
+ stdout = res.stdout.strip()
129
+ stderr = res.stderr.strip()
130
+
131
+ if not stdout:
132
+ err_msg = stderr or f"Process exited with code {res.returncode} and empty stdout"
133
+ return CodingAgentResult(
134
+ ok=False, error=err_msg,
135
+ session_id=request.resume_session_id or request.session_id, model=request.model,
136
+ duration_ms=elapsed_ms,
137
+ )
138
+
139
+ try:
140
+ payload = json.loads(stdout)
141
+ except json.JSONDecodeError:
142
+ err_msg = f"Failed to parse JSON response envelope from agy. Raw stdout: {stdout[:500]}"
143
+ if stderr:
144
+ err_msg += f" | stderr: {stderr[:500]}"
145
+ return CodingAgentResult(
146
+ ok=False, error=err_msg,
147
+ session_id=request.resume_session_id or request.session_id, model=request.model,
148
+ duration_ms=elapsed_ms,
149
+ )
150
+
151
+ # The REAL session id going forward -- never what was requested,
152
+ # see module docstring limitation 3.
153
+ session_id = payload.get("conversation_id") or request.resume_session_id or request.session_id
154
+ status = payload.get("status")
155
+ usage = payload.get("usage") or {}
156
+ duration_seconds = payload.get("duration_seconds")
157
+ duration_ms = int(duration_seconds * 1000) if duration_seconds is not None else elapsed_ms
158
+
159
+ if res.returncode != 0 or status != "SUCCESS":
160
+ err_msg = payload.get("error") or payload.get("response") or stderr or f"agy reported status={status} (exit code {res.returncode})"
161
+ return CodingAgentResult(
162
+ ok=False, error=str(err_msg), session_id=session_id, model=request.model,
163
+ usage=usage, cost_usd=None, duration_ms=duration_ms,
164
+ )
165
+
166
+ total_tokens = usage.get("total_tokens", 0)
167
+ if total_tokens > self.max_tokens_limit:
168
+ return CodingAgentResult(
169
+ ok=False,
170
+ error=(
171
+ f"Token usage limit exceeded: call used {total_tokens} total tokens "
172
+ f"(limit is {self.max_tokens_limit} tokens)"
173
+ ),
174
+ session_id=session_id, model=request.model, usage=usage, cost_usd=None, duration_ms=duration_ms,
175
+ )
176
+
177
+ return CodingAgentResult(
178
+ ok=True, error=None, session_id=session_id, model=request.model,
179
+ usage=usage, cost_usd=None, duration_ms=duration_ms,
180
+ )
@@ -0,0 +1,241 @@
1
+ """Claude Code harness -- `claude -p` CLI subprocess. No API key required.
2
+
3
+ Everything in this file is genuinely Claude-Code-specific: exact CLI flags,
4
+ the --output-format json / --input-format stream-json envelope shapes,
5
+ session_id/usage/cost field names. Split out of client.py 2026-07-31 so the
6
+ common dispatch layer (client.py) and per-harness implementations (this
7
+ file, agy.py) are physically separated, not just logically -- see
8
+ llm/harness/__init__.py's docstring for the contract every file here must
9
+ implement.
10
+ """
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from pcp.llm.ledger import _log_usage
20
+
21
+ _MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
22
+
23
+
24
+ def _claude_bin() -> str:
25
+ return os.environ.get("PCP_CLAUDE_BIN", "claude")
26
+
27
+
28
+ def _timeout() -> int:
29
+ return int(os.environ.get("PCP_LLM_TIMEOUT", "300"))
30
+
31
+
32
+ def _call_claude(system: str, user: str, model: str | None = None, pcp_dir: Path | None = None,
33
+ command: str = "llm.call", return_meta: bool = False) -> str | tuple[str, dict]:
34
+ """Run prompt through claude CLI (one-shot, no session reuse). Returns text output.
35
+
36
+ model: explicit model for this call site (e.g. JUDGE_MODEL). PCP_MODEL env overrides
37
+ everything if set, so a human can force a model for debugging.
38
+ pcp_dir/command: if given, usage+cost is appended to .pcp/token_ledger.yaml.
39
+ return_meta: if True, returns (text, meta) where meta has model/session_id/usage/
40
+ cost_usd/duration_ms — for callers building richer per-event telemetry (see telemetry.py).
41
+ """
42
+ prompt = f"{system}\n\n---\n\n{user}"
43
+
44
+ resolved_model = os.environ.get("PCP_MODEL") or model
45
+ cmd = [_claude_bin(), "-p", "--output-format", "json"]
46
+ if resolved_model:
47
+ cmd += ["--model", resolved_model]
48
+
49
+ # Real bug, found 2026-07-08: this call never passed cwd, so it always
50
+ # ran in whatever the calling PROCESS's actual OS cwd happened to be --
51
+ # not necessarily the target project. Harmless when the CLI is invoked
52
+ # from the project root (the common case), actively wrong otherwise: a
53
+ # test process (or any caller) with a different cwd would silently run
54
+ # the agent against the wrong directory. pcp_dir is already passed by
55
+ # every call site for token-ledger logging, so it doubles as the correct
56
+ # anchor here — project_root = pcp_dir.parent.
57
+ cwd = Path(pcp_dir).parent if pcp_dir else None
58
+
59
+ try:
60
+ result = subprocess.run(
61
+ cmd,
62
+ input=prompt,
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=_timeout(),
66
+ cwd=cwd,
67
+ )
68
+ except FileNotFoundError:
69
+ raise RuntimeError(
70
+ f"claude CLI not found at '{_claude_bin()}'. "
71
+ "Install Claude Code: https://claude.ai/download"
72
+ )
73
+ except subprocess.TimeoutExpired:
74
+ raise RuntimeError(f"claude CLI timed out after {_timeout()}s")
75
+
76
+ if result.returncode != 0:
77
+ raise RuntimeError(
78
+ f"claude CLI exited {result.returncode}: {result.stderr.strip()}"
79
+ )
80
+
81
+ try:
82
+ envelope = json.loads(result.stdout)
83
+ except json.JSONDecodeError:
84
+ # Fallback: older CLI or unexpected output — treat stdout as raw text.
85
+ text = result.stdout.strip()
86
+ return (text, {}) if return_meta else text
87
+
88
+ if envelope.get("is_error"):
89
+ raise RuntimeError(f"claude CLI returned an error: {envelope.get('result', '')}")
90
+
91
+ _log_usage(
92
+ pcp_dir, command, resolved_model, envelope.get("session_id"),
93
+ envelope.get("usage", {}), envelope.get("total_cost_usd"),
94
+ )
95
+
96
+ text = (envelope.get("result") or "").strip()
97
+ if not return_meta:
98
+ return text
99
+
100
+ meta = {
101
+ "model": resolved_model or "default",
102
+ "session_id": envelope.get("session_id"),
103
+ "usage": envelope.get("usage", {}),
104
+ "cost_usd": envelope.get("total_cost_usd"),
105
+ "duration_ms": envelope.get("duration_ms"),
106
+ }
107
+ return text, meta
108
+
109
+
110
+ def call_with_images(system: str, user: str, image_paths: list[Path], model: str | None = None,
111
+ pcp_dir: Path | None = None, command: str = "llm.call_with_images",
112
+ return_meta: bool = False) -> str | tuple[str, dict]:
113
+ """Same contract as _call_claude(), but attaches one or more images as
114
+ inline multimodal content blocks. Plain text stdin (what _call_claude()
115
+ uses) has no image channel -- `claude -p` only accepts an image via
116
+ `--input-format stream-json` (a single JSON message with text + image
117
+ content blocks), which in turn requires `--output-format stream-json`
118
+ (confirmed by the CLI's own error: "requires --verbose" once stream-json
119
+ output is set). The final envelope is the last `type: "result"` line on
120
+ stdout -- same fields (result/session_id/usage/total_cost_usd) as
121
+ --output-format json.
122
+
123
+ Claude-Code-only, genuinely -- not part of the harness dispatch seam in
124
+ client.py (no agy equivalent exists). Enables VLM-based checks
125
+ (uat.check_visual_quality) that judge a rendered screenshot -- optionally
126
+ against a second reference image -- without requiring the coding agent
127
+ itself to have filesystem access to the image at judge-call time.
128
+ Verified live 2026-07-18: the model sometimes first attempts a Read tool
129
+ call on a hallucinated file path for an inline image before falling back
130
+ to inspecting it directly -- the explicit instruction below cuts that
131
+ wasted turn most of the time but not always; harmless either way, just
132
+ an extra attempt."""
133
+ content = [{
134
+ "type": "text",
135
+ "text": (
136
+ f"{system}\n\n---\n\n{user}\n\n"
137
+ f"{'The image is' if len(image_paths) == 1 else 'The images are'} attached "
138
+ "inline in this message's content as image blocks -- not files on disk. "
139
+ "Do not attempt to Read a file path for them; analyze the attached "
140
+ "image(s) directly."
141
+ ),
142
+ }]
143
+ for image_path in image_paths:
144
+ media_type = _MEDIA_TYPES.get(image_path.suffix.lower(), "image/png")
145
+ data = base64.b64encode(image_path.read_bytes()).decode()
146
+ content.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
147
+ message = {"type": "user", "message": {"role": "user", "content": content}}
148
+
149
+ resolved_model = os.environ.get("PCP_MODEL") or model
150
+ cmd = [_claude_bin(), "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]
151
+ if resolved_model:
152
+ cmd += ["--model", resolved_model]
153
+ cwd = Path(pcp_dir).parent if pcp_dir else None
154
+
155
+ try:
156
+ result = subprocess.run(
157
+ cmd, input=json.dumps(message), capture_output=True, text=True,
158
+ timeout=_timeout(), cwd=cwd,
159
+ )
160
+ except FileNotFoundError:
161
+ raise RuntimeError(
162
+ f"claude CLI not found at '{_claude_bin()}'. "
163
+ "Install Claude Code: https://claude.ai/download"
164
+ )
165
+ except subprocess.TimeoutExpired:
166
+ raise RuntimeError(f"claude CLI timed out after {_timeout()}s")
167
+
168
+ if result.returncode != 0:
169
+ raise RuntimeError(
170
+ f"claude CLI exited {result.returncode}: {result.stderr.strip()}"
171
+ )
172
+
173
+ envelope = None
174
+ for line in result.stdout.splitlines():
175
+ line = line.strip()
176
+ if not line:
177
+ continue
178
+ try:
179
+ obj = json.loads(line)
180
+ except json.JSONDecodeError:
181
+ continue
182
+ if obj.get("type") == "result":
183
+ envelope = obj
184
+ if envelope is None:
185
+ raise RuntimeError("claude CLI stream-json output had no result event")
186
+
187
+ if envelope.get("is_error"):
188
+ raise RuntimeError(f"claude CLI returned an error: {envelope.get('result', '')}")
189
+
190
+ _log_usage(
191
+ pcp_dir, command, resolved_model, envelope.get("session_id"),
192
+ envelope.get("usage", {}), envelope.get("total_cost_usd"),
193
+ )
194
+
195
+ text = (envelope.get("result") or "").strip()
196
+ if not return_meta:
197
+ return text
198
+
199
+ meta = {
200
+ "model": resolved_model or "default",
201
+ "session_id": envelope.get("session_id"),
202
+ "usage": envelope.get("usage", {}),
203
+ "cost_usd": envelope.get("total_cost_usd"),
204
+ "duration_ms": envelope.get("duration_ms"),
205
+ }
206
+ return text, meta
207
+
208
+
209
+ def call_with_image(system: str, user: str, image_path: Path, model: str | None = None,
210
+ pcp_dir: Path | None = None, command: str = "llm.call_with_image",
211
+ return_meta: bool = False) -> str | tuple[str, dict]:
212
+ """Single-image convenience wrapper over call_with_images()."""
213
+ return call_with_images(
214
+ system, user, [image_path], model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
215
+ )
216
+
217
+
218
+ def call_json_with_images(system: str, user: str, image_paths: list[Path], model: str | None = None,
219
+ pcp_dir: Path | None = None, command: str = "llm.call_json_with_images",
220
+ return_meta: bool = False) -> Any:
221
+ """call_with_images + JSON parsing, same contract as client.call_json()."""
222
+ out = call_with_images(
223
+ system, user + "\n\nRespond with valid JSON only. No markdown fences.",
224
+ image_paths, model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
225
+ )
226
+ text, meta = out if return_meta else (out, None)
227
+ text = text.strip()
228
+ if text.startswith("```"):
229
+ lines = text.split("\n")
230
+ text = "\n".join(lines[1:-1])
231
+ parsed = json.loads(text)
232
+ return (parsed, meta) if return_meta else parsed
233
+
234
+
235
+ def call_json_with_image(system: str, user: str, image_path: Path, model: str | None = None,
236
+ pcp_dir: Path | None = None, command: str = "llm.call_json_with_image",
237
+ return_meta: bool = False) -> Any:
238
+ """Single-image convenience wrapper over call_json_with_images()."""
239
+ return call_json_with_images(
240
+ system, user, [image_path], model=model, pcp_dir=pcp_dir, command=command, return_meta=return_meta,
241
+ )
pcp/llm/ledger.py ADDED
@@ -0,0 +1,47 @@
1
+ """token_ledger.yaml — usage/cost logging shared by every harness.
2
+
3
+ Split out of client.py 2026-07-31 (harness/common split, see llm/harness/
4
+ package docstring) -- this is PCP's own ledger format, not a harness
5
+ implementation detail, so it lives independent of both client.py's
6
+ dispatch layer and any individual harness/*.py file to avoid a circular
7
+ import between them (client.py -> harness.claude -> needs _log_usage ->
8
+ would need client.py back, without this module in between).
9
+ """
10
+
11
+ import threading
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ # Guards token_ledger.yaml's read-modify-write -- gate checks in build.py's
18
+ # per-criterion loop that make an LLM call (architect-review, gate,
19
+ # design-justification) run concurrently with each other (2026-07-18,
20
+ # Project O dogfood finding: gate stages were needlessly sequential).
21
+ # Without this lock, two concurrent calls reading the same ledger snapshot
22
+ # before either writes back would silently drop one call's usage record.
23
+ _LEDGER_LOCK = threading.Lock()
24
+
25
+
26
+ def _log_usage(pcp_dir: Path | None, command: str, model: str | None, session_id: str | None,
27
+ usage: dict, cost_usd: float | None) -> None:
28
+ if pcp_dir is None:
29
+ return
30
+ with _LEDGER_LOCK:
31
+ ledger_path = Path(pcp_dir) / "token_ledger.yaml"
32
+ entries = []
33
+ if ledger_path.exists():
34
+ data = yaml.safe_load(ledger_path.read_text()) or {}
35
+ entries = data.get("calls", [])
36
+ entries.append({
37
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
38
+ "command": command,
39
+ "model": model or "default",
40
+ "session_id": session_id,
41
+ "input_tokens": usage.get("input_tokens", 0),
42
+ "output_tokens": usage.get("output_tokens", 0),
43
+ "cache_read_tokens": usage.get("cache_read_input_tokens", 0),
44
+ "cache_creation_tokens": usage.get("cache_creation_input_tokens", 0),
45
+ "cost_usd": cost_usd,
46
+ })
47
+ ledger_path.write_text(yaml.dump({"calls": entries}, default_flow_style=False))