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/spec_write.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Shared propose -> real-diff -> human-approve -> write mechanic for every
|
|
2
|
+
human-authorized `.pcp/` file.
|
|
3
|
+
|
|
4
|
+
Hard Rule #2 is "human-AUTHORIZED only", not "human-typed only". The
|
|
5
|
+
`protected_path` ci_rule it rides on has always meant exactly that -- read
|
|
6
|
+
`check.run_protected_path_rule`: it returns zero violations unless
|
|
7
|
+
`PCP_AGENT_SESSION=1`, i.e. it only ever blocks `pcp build`'s unattended
|
|
8
|
+
coding agent. A human-present session was never blocked by code.
|
|
9
|
+
|
|
10
|
+
What DID block it was doctrine wording. "Spec files are human-written only /
|
|
11
|
+
never edit these as an agent" (scaffolded into every PCP project's CLAUDE.md
|
|
12
|
+
by `pcp init`) told agents to refuse, and `pcp init`'s own escape hatch --
|
|
13
|
+
"propose changes via `pcp pm`" -- was a dead end for 6 of the 10 protected
|
|
14
|
+
paths, because `pm` only ever writes module spec.yaml/acceptance.yaml. So a
|
|
15
|
+
detailed feature discussion would end with "update the specs", the agent
|
|
16
|
+
would decline on doctrine, and nothing got written. Reported by Ganesh
|
|
17
|
+
2026-07-25 as a recurring, cross-project failure.
|
|
18
|
+
|
|
19
|
+
This module is the generalisation of what `pcp correct-objective` already did
|
|
20
|
+
for objective.md/target_state.md: an LLM proposes the rewrite from stated
|
|
21
|
+
intent, the human sees a real unified diff of every affected file, approves,
|
|
22
|
+
and only then is anything written. Every human-authorized file now has such a
|
|
23
|
+
path (`pcp amend`), so "human-authorized" is an achievable instruction rather
|
|
24
|
+
than a refusal.
|
|
25
|
+
|
|
26
|
+
Guardrail for the governance files (ci_rules.yaml, controls.yaml,
|
|
27
|
+
SDLC_phase.yaml): an LLM proposing edits to the rules that police it is a
|
|
28
|
+
genuine self-reference risk. Those three are schema-validated before write,
|
|
29
|
+
and any proposal that deletes a rule/control or downgrades a hard_block is
|
|
30
|
+
refused unless the human passes --allow-weakening (and it is recorded either
|
|
31
|
+
way). Approval alone is not enough there -- weakening must be deliberate and
|
|
32
|
+
named.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import difflib
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import click
|
|
42
|
+
import yaml
|
|
43
|
+
from rich.console import Console
|
|
44
|
+
|
|
45
|
+
from pcp import decision_log
|
|
46
|
+
from pcp.llm import client as llm
|
|
47
|
+
from pcp.schema import validator
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SpecTarget:
|
|
54
|
+
"""One human-authorized file participating in a propose/approve write."""
|
|
55
|
+
|
|
56
|
+
name: str # display + diff label, e.g. "strategy/decomposition.md"
|
|
57
|
+
path: Path
|
|
58
|
+
key: str # JSON key the LLM returns the full replacement content under
|
|
59
|
+
schema: str | None = None # schema name for validator.validate_file, if any
|
|
60
|
+
guarded: bool = False # True => weakening check applies (governance files)
|
|
61
|
+
description: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class WriteResult:
|
|
66
|
+
written: bool
|
|
67
|
+
result: dict = field(default_factory=dict)
|
|
68
|
+
reason: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_diff(old: str, new: str, name: str) -> str:
|
|
72
|
+
return "\n".join(
|
|
73
|
+
difflib.unified_diff(
|
|
74
|
+
old.splitlines(),
|
|
75
|
+
new.splitlines(),
|
|
76
|
+
fromfile=f"a/{name}",
|
|
77
|
+
tofile=f"b/{name}",
|
|
78
|
+
lineterm="",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_output_schema(targets: list[SpecTarget]) -> str:
|
|
84
|
+
"""The JSON output contract to append to a caller's system prompt, so every
|
|
85
|
+
amend prompt describes its return shape identically."""
|
|
86
|
+
lines = ["{"]
|
|
87
|
+
for t in targets:
|
|
88
|
+
lines.append(f' "{t.key}": "full replacement content of {t.name}",')
|
|
89
|
+
lines.append(' "summary": "one paragraph: exactly what changed and why, for the audit trail"')
|
|
90
|
+
lines.append("}")
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _schema_errors(text: str, schema_name: str) -> list[str]:
|
|
95
|
+
"""Validate proposed content without writing it to its real path first."""
|
|
96
|
+
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fh:
|
|
97
|
+
fh.write(text)
|
|
98
|
+
tmp = Path(fh.name)
|
|
99
|
+
try:
|
|
100
|
+
return validator.validate_file(tmp, schema_name)
|
|
101
|
+
finally:
|
|
102
|
+
tmp.unlink(missing_ok=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
HARD = "hard_block"
|
|
106
|
+
_SEVERITY_RANK = {"advisory": 0, "warn": 1, "warning": 1, HARD: 2}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect_weakening(old_text: str, new_text: str) -> list[str]:
|
|
110
|
+
"""Governance-file weakening detector: removed rules/controls, or a
|
|
111
|
+
severity downgraded away from hard_block. Deterministic (rung 1) -- never
|
|
112
|
+
an LLM judging whether its own proposed edit weakened the gate.
|
|
113
|
+
|
|
114
|
+
Unparseable-old is treated as no findings (nothing trustworthy to compare);
|
|
115
|
+
unparseable-new is caught by the schema check, not here."""
|
|
116
|
+
try:
|
|
117
|
+
old = yaml.safe_load(old_text) or {}
|
|
118
|
+
new = yaml.safe_load(new_text) or {}
|
|
119
|
+
except yaml.YAMLError:
|
|
120
|
+
return []
|
|
121
|
+
if not isinstance(old, dict) or not isinstance(new, dict):
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
findings = []
|
|
125
|
+
for collection in ("rules", "controls"):
|
|
126
|
+
old_items = {i.get("id"): i for i in (old.get(collection) or []) if isinstance(i, dict) and i.get("id")}
|
|
127
|
+
new_items = {i.get("id"): i for i in (new.get(collection) or []) if isinstance(i, dict) and i.get("id")}
|
|
128
|
+
for rid in sorted(set(old_items) - set(new_items)):
|
|
129
|
+
findings.append(f"{collection}: {rid} removed")
|
|
130
|
+
for rid in sorted(set(old_items) & set(new_items)):
|
|
131
|
+
old_sev = str(old_items[rid].get("severity", "")).lower()
|
|
132
|
+
new_sev = str(new_items[rid].get("severity", "")).lower()
|
|
133
|
+
if old_sev == new_sev:
|
|
134
|
+
continue
|
|
135
|
+
if _SEVERITY_RANK.get(new_sev, 0) < _SEVERITY_RANK.get(old_sev, 0):
|
|
136
|
+
findings.append(f"{collection}: {rid} severity {old_sev} -> {new_sev} (downgrade)")
|
|
137
|
+
|
|
138
|
+
# SDLC_phase.yaml has no rules/controls list — dropped phases, or dropped
|
|
139
|
+
# exit criteria within a surviving phase, are the equivalent weakening.
|
|
140
|
+
old_phases = {p.get("name"): p for p in (old.get("phases") or []) if isinstance(p, dict) and p.get("name")}
|
|
141
|
+
new_phases = {p.get("name"): p for p in (new.get("phases") or []) if isinstance(p, dict) and p.get("name")}
|
|
142
|
+
for name in sorted(set(old_phases) - set(new_phases)):
|
|
143
|
+
findings.append(f"phases: {name} removed")
|
|
144
|
+
for name in sorted(set(old_phases) & set(new_phases)):
|
|
145
|
+
old_ids = {c.get("id") for c in (old_phases[name].get("exit_criteria") or []) if isinstance(c, dict)}
|
|
146
|
+
new_ids = {c.get("id") for c in (new_phases[name].get("exit_criteria") or []) if isinstance(c, dict)}
|
|
147
|
+
for cid in sorted(old_ids - new_ids):
|
|
148
|
+
findings.append(f"phases.{name}.exit_criteria: {cid} removed")
|
|
149
|
+
|
|
150
|
+
return findings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def propose_and_write(
|
|
154
|
+
pcp_dir: Path,
|
|
155
|
+
targets: list[SpecTarget],
|
|
156
|
+
system_prompt: str,
|
|
157
|
+
user_prompt: str,
|
|
158
|
+
*,
|
|
159
|
+
command: str,
|
|
160
|
+
intent: str,
|
|
161
|
+
model: str | None = None,
|
|
162
|
+
yes: bool = False,
|
|
163
|
+
allow_weakening: bool = False,
|
|
164
|
+
decision_category: str = "architecture",
|
|
165
|
+
no_change_hint: str = "",
|
|
166
|
+
) -> WriteResult:
|
|
167
|
+
"""Draft a rewrite of every target, show a real diff, require approval, write.
|
|
168
|
+
|
|
169
|
+
Exits(2) on LLM error, a missing key in the response, a schema violation, or
|
|
170
|
+
unapproved weakening of a guarded file. Returns WriteResult(written=False)
|
|
171
|
+
for the benign outcomes (nothing changed, human declined) so callers can
|
|
172
|
+
keep their own exit code at 0.
|
|
173
|
+
"""
|
|
174
|
+
old_texts = {t.key: (t.path.read_text() if t.path.exists() else "") for t in targets}
|
|
175
|
+
|
|
176
|
+
console.print(f"[dim]Drafting {', '.join(t.name for t in targets)} rewrite...[/dim]")
|
|
177
|
+
try:
|
|
178
|
+
result = llm.call_json(
|
|
179
|
+
system_prompt,
|
|
180
|
+
user_prompt,
|
|
181
|
+
model=model or llm.BUILD_MODEL,
|
|
182
|
+
pcp_dir=pcp_dir,
|
|
183
|
+
command=command,
|
|
184
|
+
)
|
|
185
|
+
except (RuntimeError, ValueError) as e:
|
|
186
|
+
console.print(f"[red]Error calling LLM:[/red] {e}")
|
|
187
|
+
sys.exit(2)
|
|
188
|
+
|
|
189
|
+
new_texts = {}
|
|
190
|
+
for t in targets:
|
|
191
|
+
content = result.get(t.key, "")
|
|
192
|
+
if not content:
|
|
193
|
+
console.print(f"[red]Error:[/red] LLM did not return content for {t.name}.")
|
|
194
|
+
sys.exit(2)
|
|
195
|
+
new_texts[t.key] = content
|
|
196
|
+
|
|
197
|
+
diffs = {t.name: render_diff(old_texts[t.key], new_texts[t.key], t.name) for t in targets}
|
|
198
|
+
if not any(diffs.values()):
|
|
199
|
+
console.print(
|
|
200
|
+
"[yellow]No changes -- the stated change doesn't appear to require a rewrite. "
|
|
201
|
+
f"{no_change_hint or 'If this is unexpected, restate it more concretely.'}[/yellow]"
|
|
202
|
+
)
|
|
203
|
+
return WriteResult(written=False, result=result, reason="no-change")
|
|
204
|
+
|
|
205
|
+
# Schema + weakening checks run BEFORE the human is asked to approve, so an
|
|
206
|
+
# invalid or gate-weakening proposal never even reaches the prompt.
|
|
207
|
+
for t in targets:
|
|
208
|
+
if not diffs[t.name]:
|
|
209
|
+
continue
|
|
210
|
+
if t.schema:
|
|
211
|
+
errors = _schema_errors(new_texts[t.key], t.schema)
|
|
212
|
+
if errors:
|
|
213
|
+
console.print(f"[red]Error:[/red] proposed {t.name} fails its schema — not written.")
|
|
214
|
+
for err in errors[:10]:
|
|
215
|
+
console.print(f" {err}")
|
|
216
|
+
sys.exit(2)
|
|
217
|
+
if t.guarded:
|
|
218
|
+
weakened = detect_weakening(old_texts[t.key], new_texts[t.key])
|
|
219
|
+
if weakened and not allow_weakening:
|
|
220
|
+
console.print(
|
|
221
|
+
f"[red]Refused:[/red] proposed {t.name} weakens the gates that police this project:"
|
|
222
|
+
)
|
|
223
|
+
for w in weakened:
|
|
224
|
+
console.print(f" {w}")
|
|
225
|
+
console.print(
|
|
226
|
+
"\n[dim]Approval alone is not enough for a governance file. Re-run with "
|
|
227
|
+
"--allow-weakening if this is deliberate — it is recorded in decision_log.jsonl.[/dim]"
|
|
228
|
+
)
|
|
229
|
+
sys.exit(2)
|
|
230
|
+
|
|
231
|
+
console.print(f"\n[bold]Summary:[/bold] {result.get('summary', '')}\n")
|
|
232
|
+
for t in targets:
|
|
233
|
+
if diffs[t.name]:
|
|
234
|
+
console.print(f"[bold]{t.name} diff:[/bold]")
|
|
235
|
+
console.print(diffs[t.name])
|
|
236
|
+
console.print("")
|
|
237
|
+
|
|
238
|
+
changed = [t.name for t in targets if diffs[t.name]]
|
|
239
|
+
if not yes and not click.confirm(f"Apply this rewrite to {', '.join(changed)}?"):
|
|
240
|
+
console.print("[yellow]Aborted -- files unchanged.[/yellow]")
|
|
241
|
+
return WriteResult(written=False, result=result, reason="declined")
|
|
242
|
+
|
|
243
|
+
weakenings = []
|
|
244
|
+
for t in targets:
|
|
245
|
+
if not diffs[t.name]:
|
|
246
|
+
continue
|
|
247
|
+
if t.guarded:
|
|
248
|
+
weakenings += [f"{t.name}: {w}" for w in detect_weakening(old_texts[t.key], new_texts[t.key])]
|
|
249
|
+
t.path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
t.path.write_text(new_texts[t.key])
|
|
251
|
+
console.print(f"[green]✓[/green] {', '.join(changed)} rewritten.")
|
|
252
|
+
|
|
253
|
+
evidence = result.get("summary", "")
|
|
254
|
+
if weakenings:
|
|
255
|
+
console.print("[yellow]⚠ gate weakening applied with --allow-weakening:[/yellow]")
|
|
256
|
+
for w in weakenings:
|
|
257
|
+
console.print(f" {w}")
|
|
258
|
+
evidence = f"{evidence}\n\nGATE WEAKENING (--allow-weakening): " + "; ".join(weakenings)
|
|
259
|
+
|
|
260
|
+
decision_log.record(
|
|
261
|
+
pcp_dir,
|
|
262
|
+
source=command,
|
|
263
|
+
session_id=None,
|
|
264
|
+
category=decision_category,
|
|
265
|
+
summary=f"{', '.join(changed)} amended: {intent}",
|
|
266
|
+
evidence=evidence,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return WriteResult(written=True, result=result)
|
pcp/spend.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Rolling project-level spend ceiling.
|
|
2
|
+
|
|
3
|
+
Closes the gap found in the 2026-07-17 market sweep: PCP bounded individual
|
|
4
|
+
attempts (PCP_BUILD_AGENT_MAX_BUDGET_USD) and runs (PCP_MAX_BUILD_SESSIONS)
|
|
5
|
+
but nothing capped cumulative project spend over time — the primitive
|
|
6
|
+
Portkey's auto-expiring budget keys and OpenRouter's hard spend guardrails
|
|
7
|
+
treat as table stakes. Reads .pcp/token_ledger.yaml (already written by every
|
|
8
|
+
LLM call), no new bookkeeping.
|
|
9
|
+
|
|
10
|
+
Ceilings (both optional, unset = unlimited, matching every other PCP cap):
|
|
11
|
+
PCP_PROJECT_BUDGET_USD — lifetime ceiling for this project
|
|
12
|
+
PCP_PROJECT_DAILY_BUDGET_USD — rolling ceiling for the current UTC day
|
|
13
|
+
|
|
14
|
+
Enforcement is refuse-loudly-at-session-start (build/watch check before
|
|
15
|
+
spawning a new agent session), never mid-attempt kill — an attempt already
|
|
16
|
+
paid for gets to finish and be recorded.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ledger_costs(pcp_dir: Path) -> list[tuple[str, float]]:
|
|
27
|
+
path = Path(pcp_dir) / "token_ledger.yaml"
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
32
|
+
except yaml.YAMLError:
|
|
33
|
+
return []
|
|
34
|
+
out = []
|
|
35
|
+
for c in data.get("calls", []):
|
|
36
|
+
cost = c.get("cost_usd")
|
|
37
|
+
if isinstance(cost, (int, float)):
|
|
38
|
+
out.append((str(c.get("timestamp", "")), float(cost)))
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def project_spend(pcp_dir: Path) -> dict:
|
|
43
|
+
"""Total and today's (UTC) spend from the ledger."""
|
|
44
|
+
costs = _ledger_costs(pcp_dir)
|
|
45
|
+
today_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
46
|
+
return {
|
|
47
|
+
"total_usd": round(sum(c for _, c in costs), 4),
|
|
48
|
+
"today_usd": round(sum(c for ts, c in costs if ts.startswith(today_prefix)), 4),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_ceiling(pcp_dir: Path) -> tuple[bool, str]:
|
|
53
|
+
"""(allowed, reason). Allowed unless a configured ceiling is breached."""
|
|
54
|
+
total_cap = os.environ.get("PCP_PROJECT_BUDGET_USD")
|
|
55
|
+
daily_cap = os.environ.get("PCP_PROJECT_DAILY_BUDGET_USD")
|
|
56
|
+
if not total_cap and not daily_cap:
|
|
57
|
+
return True, "no project spend ceiling configured"
|
|
58
|
+
spend = project_spend(pcp_dir)
|
|
59
|
+
if total_cap:
|
|
60
|
+
try:
|
|
61
|
+
if spend["total_usd"] >= float(total_cap):
|
|
62
|
+
return False, (
|
|
63
|
+
f"project spend ${spend['total_usd']:.2f} >= ceiling ${float(total_cap):.2f} "
|
|
64
|
+
"(PCP_PROJECT_BUDGET_USD) — raise the ceiling or archive the ledger to proceed"
|
|
65
|
+
)
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
if daily_cap:
|
|
69
|
+
try:
|
|
70
|
+
if spend["today_usd"] >= float(daily_cap):
|
|
71
|
+
return False, (
|
|
72
|
+
f"today's spend ${spend['today_usd']:.2f} >= daily ceiling ${float(daily_cap):.2f} "
|
|
73
|
+
"(PCP_PROJECT_DAILY_BUDGET_USD) — resumes next UTC day or raise the ceiling"
|
|
74
|
+
)
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
return True, "within configured ceilings"
|
pcp/symbols.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Per-symbol AST fingerprints (Swimm token-tracking / Fiberplane drift.lock
|
|
2
|
+
reference patterns, 2026-07-17, build plan 3.4).
|
|
3
|
+
|
|
4
|
+
File-level change detection can't distinguish "this file was touched" from
|
|
5
|
+
"the symbol a criterion actually depends on changed". Fingerprinting each
|
|
6
|
+
top-level function/class by a normalized AST hash (structure + names, not
|
|
7
|
+
whitespace/comments/positions) lets `pcp scan` report symbol-level churn —
|
|
8
|
+
the noise-reduction the drift-tool field converged on.
|
|
9
|
+
|
|
10
|
+
Python-only today (ast stdlib); other languages report no symbols rather than
|
|
11
|
+
guessing. Deterministic, zero LLM.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
FINGERPRINTS_FILE = "symbol_fingerprints.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fingerprint_python_file(path: Path) -> dict[str, str]:
|
|
23
|
+
import warnings
|
|
24
|
+
try:
|
|
25
|
+
# Scanned projects' own SyntaxWarnings (invalid escape sequences etc.)
|
|
26
|
+
# are their lint problem, not scan output — suppress here or every
|
|
27
|
+
# `pcp scan` re-emits them for files PCP merely fingerprints.
|
|
28
|
+
with warnings.catch_warnings():
|
|
29
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
30
|
+
tree = ast.parse(path.read_text(errors="replace"))
|
|
31
|
+
except (SyntaxError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
out = {}
|
|
34
|
+
for node in tree.body:
|
|
35
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
36
|
+
# ast.dump without attributes normalizes away line/col/whitespace;
|
|
37
|
+
# docstrings remain part of the structure deliberately — a
|
|
38
|
+
# contract-describing docstring change IS a symbol change.
|
|
39
|
+
digest = hashlib.sha256(ast.dump(node, annotate_fields=False).encode()).hexdigest()[:16]
|
|
40
|
+
out[node.name] = digest
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fingerprint_project(project_root: Path) -> dict[str, dict[str, str]]:
|
|
45
|
+
result = {}
|
|
46
|
+
for p in sorted(project_root.rglob("*.py")):
|
|
47
|
+
if any(seg in p.parts for seg in ("__pycache__", ".venv", "venv", "node_modules", ".git", ".pcp")):
|
|
48
|
+
continue
|
|
49
|
+
fp = fingerprint_python_file(p)
|
|
50
|
+
if fp:
|
|
51
|
+
result[str(p.relative_to(project_root))] = fp
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def diff_fingerprints(old: dict, new: dict) -> dict:
|
|
56
|
+
"""{changed: [file:symbol], added: [...], removed: [...]}."""
|
|
57
|
+
changed, added, removed = [], [], []
|
|
58
|
+
for f, symbols in new.items():
|
|
59
|
+
old_symbols = old.get(f, {})
|
|
60
|
+
for name, h in symbols.items():
|
|
61
|
+
if name not in old_symbols:
|
|
62
|
+
added.append(f"{f}:{name}")
|
|
63
|
+
elif old_symbols[name] != h:
|
|
64
|
+
changed.append(f"{f}:{name}")
|
|
65
|
+
for name in old_symbols:
|
|
66
|
+
if name not in symbols:
|
|
67
|
+
removed.append(f"{f}:{name}")
|
|
68
|
+
for f in old:
|
|
69
|
+
if f not in new:
|
|
70
|
+
removed.extend(f"{f}:{name}" for name in old[f])
|
|
71
|
+
return {"changed": changed, "added": added, "removed": removed}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def update_fingerprints(pcp_dir: Path) -> dict:
|
|
75
|
+
"""Recompute, diff vs stored, persist, return the diff summary."""
|
|
76
|
+
path = pcp_dir / FINGERPRINTS_FILE
|
|
77
|
+
old = {}
|
|
78
|
+
if path.exists():
|
|
79
|
+
try:
|
|
80
|
+
old = json.loads(path.read_text())
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
old = {}
|
|
83
|
+
new = fingerprint_project(pcp_dir.parent)
|
|
84
|
+
delta = diff_fingerprints(old, new)
|
|
85
|
+
path.write_text(json.dumps(new, indent=1, sort_keys=True))
|
|
86
|
+
return delta
|