Skip to content

Commit 1087285

Browse files
jmcentireclaude
andcommitted
v0.4.0: Complete R2/R3 reliability and performance improvements
- Fix Opus 4.6 pricing ($15/$75 -> $5/$25 per million tokens) - Add BudgetTracker.summary()/as_dict() for structured budget introspection - Add dynamic pricing: pact pricing command, ~/.config/pact/model_pricing.json - Wire quality.py audit into contract authoring and validation gate - Wire structured_side_effects into contract + test authoring prompts - Wire performance_budget into contract + test authoring prompts - Wire AuditedAnswer provenance tracking into InterviewResult + CLI approve - Apply dependency name normalization in main validation gate - Add pact_resume tool to MCP server - Optimize implementer.py handoff briefs (include_test_code=False) - 1501 tests passing, README and landing page updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e88b33c commit 1087285

20 files changed

Lines changed: 491 additions & 35 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ Thumbs.db
3636
.env
3737
*.key
3838
*.pem
39+
40+
# User-local pricing overrides
41+
model_pricing.json

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ pact build my-project sync_tracker --competitive --agents 3
170170
| `pact incidents <project>` | List active/recent incidents |
171171
| `pact incident <project> <id>` | Show incident details + diagnostic report |
172172
| `pact audit <project>` | Spec-compliance audit (compare task.md vs implementations) |
173+
| `pact tasks <project>` | List phase tasks with status |
174+
| `pact analyze <project>` | Run cross-artifact analysis |
175+
| `pact checklist <project>` | Generate requirements checklist |
176+
| `pact test-gen <project>` | Generate tests + security audit for any codebase |
177+
| `pact adopt <project>` | Adopt existing codebase under pact governance |
178+
| `pact pricing` | Show model pricing table (`--export` to override) |
173179

174180
## Configuration
175181

@@ -186,7 +192,7 @@ plan_only: false
186192
187193
# Override token pricing (per million tokens: [input, output])
188194
model_pricing:
189-
claude-opus-4-6: [15.00, 75.00]
195+
claude-opus-4-6: [5.00, 25.00]
190196
claude-sonnet-4-5-20250929: [3.00, 15.00]
191197
claude-haiku-4-5-20251001: [0.80, 4.00]
192198
@@ -282,7 +288,7 @@ my-project/
282288

283289
```bash
284290
make dev # Install with LLM backend support
285-
make test # Run full test suite (1482 tests)
291+
make test # Run full test suite (1501 tests)
286292
make test-quick # Stop on first failure
287293
make clean # Remove venv and caches
288294
```

docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ <h2>If AI writes the code, who debugs it at 3am?</h2>
655655
<div class="step"><span class="arrow">&#9656;</span> <span>Fixer agent spawned with contract context</span></div>
656656
<div class="step"><span class="arrow">&#9656;</span> <span>Reproducer test added to contract</span></div>
657657
<div class="step"><span class="arrow">&#9656;</span> <span>Implementation rebuilt from scratch</span></div>
658-
<div class="step"><span class="arrow">&#9656;</span> <span><span class="label">1,482 tests pass</span> <span class="dim">// verified</span></span></div>
658+
<div class="step"><span class="arrow">&#9656;</span> <span><span class="label">1,501 tests pass</span> <span class="dim">// verified</span></span></div>
659659
<div class="step"><span class="arrow">&#9656;</span> <span>Contract got stricter. Bug can't recur.</span></div>
660660
</div>
661661
</div>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pact-agents"
3-
version = "0.3.1"
3+
version = "0.4.0"
44
description = "Contract-first multi-agent software engineering. Contracts before code. Tests as law. Agents that can't cheat."
55
readme = "README.md"
66
license = {text = "MIT"}

src/pact/agents/contract_author.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
- Error cases must be exhaustive
2929
- Preconditions and postconditions must be verifiable
3030
- Dependencies must be explicitly declared
31-
- Names must be descriptive and consistent"""
31+
- Names must be descriptive and consistent
32+
- Functions with side effects must declare structured_side_effects (kind: none/reads_file/writes_file/network_call/mutates_state/logging, target, description)
33+
- Include performance_budget (p95_latency_ms, max_memory_mb, complexity) for performance-sensitive functions"""
3234

3335

3436
async def author_contract(
@@ -151,7 +153,9 @@ async def author_contract(
151153
- Include error cases for each function
152154
- Add preconditions and postconditions where meaningful
153155
- List all dependencies by component_id
154-
- All type references must resolve to types defined in this contract or primitives (str, int, float, bool, None, bytes, dict, list, any)"""
156+
- All type references must resolve to types defined in this contract or primitives (str, int, float, bool, None, bytes, dict, list, any)
157+
- Declare structured_side_effects for each function (use kind='none' for pure functions)
158+
- Set performance_budget on functions with latency or memory constraints"""
155159

156160
contract, in_tok, out_tok = await agent.assess_cached(
157161
ComponentContract, prompt, CONTRACT_SYSTEM, cache_prefix=cache_prefix,
@@ -169,4 +173,10 @@ async def author_contract(
169173
in_tok + out_tok,
170174
)
171175

176+
# Quality audit — non-blocking warnings for vague language
177+
from pact.quality import audit_contract_specificity
178+
quality_warnings = audit_contract_specificity(contract)
179+
for w in quality_warnings:
180+
logger.warning("Quality: %s", w)
181+
172182
return contract, research, plan

src/pact/agents/test_author.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ def _render_focused_contract(contract: ComponentContract) -> str:
111111
for err in f.error_cases:
112112
cond = f" when {err.condition}" if err.condition else ""
113113
parts.append(f" error: {err.name}{cond}")
114+
# Structured side effects (or fall back to string side_effects)
115+
if f.structured_side_effects:
116+
for se in f.structured_side_effects:
117+
parts.append(f" side_effect: {se.kind} -> {se.target}")
118+
elif f.side_effects:
119+
for se in f.side_effects:
120+
parts.append(f" side_effect: {se}")
121+
# Performance budget
122+
if f.performance_budget:
123+
pb = f.performance_budget
124+
budget_parts = []
125+
if pb.p95_latency_ms:
126+
budget_parts.append(f"p95<{pb.p95_latency_ms}ms")
127+
if pb.max_memory_mb:
128+
budget_parts.append(f"mem<{pb.max_memory_mb}MB")
129+
if pb.complexity:
130+
budget_parts.append(pb.complexity)
131+
if budget_parts:
132+
parts.append(f" performance: {', '.join(budget_parts)}")
114133

115134
# Invariants
116135
if contract.invariants:

src/pact/budget.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
from __future__ import annotations
88

9+
import json
910
import logging
1011
import time
1112
from dataclasses import dataclass, field
13+
from pathlib import Path
1214

1315
from pydantic import BaseModel, Field
1416

@@ -20,7 +22,7 @@
2022
# Anthropic
2123
"claude-haiku-4-5-20251001": (0.80, 4.00),
2224
"claude-sonnet-4-5-20250929": (3.00, 15.00),
23-
"claude-opus-4-6": (15.00, 75.00),
25+
"claude-opus-4-6": (5.00, 25.00),
2426
# OpenAI
2527
"gpt-4o": (2.50, 10.00),
2628
"gpt-4o-mini": (0.15, 0.60),
@@ -217,6 +219,25 @@ def cache_hit_rate(self) -> float:
217219
return 0.0
218220
return self._cache_read_tokens / total
219221

222+
def summary(self) -> dict:
223+
"""Return a dict summarizing all budget and cache metrics."""
224+
return {
225+
"project_spend": self._project_spend,
226+
"project_cap": self.per_project_cap,
227+
"budget_remaining": self.budget_remaining,
228+
"spend_percentage": self.spend_percentage,
229+
"daily_spend": self.daily_spend,
230+
"tokens_in": self._project_tokens_in,
231+
"tokens_out": self._project_tokens_out,
232+
"cache_creation_tokens": self._cache_creation_tokens,
233+
"cache_read_tokens": self._cache_read_tokens,
234+
"cache_hit_rate": self.cache_hit_rate,
235+
}
236+
237+
def as_dict(self) -> dict:
238+
"""Alias for summary()."""
239+
return self.summary()
240+
220241

221242
class PhaseBudget(BaseModel):
222243
"""Budget tracking broken down by pipeline phase."""
@@ -270,3 +291,39 @@ def from_config(cls, shaping_budget_pct: float = 0.15) -> "PhaseBudget":
270291
if shaping_budget_pct > 0:
271292
caps["shape"] = shaping_budget_pct
272293
return cls(phase_caps=caps)
294+
295+
296+
# ── External pricing file ─────────────────────────────────────────
297+
298+
299+
DEFAULT_PRICING_PATH = Path("~/.config/pact/model_pricing.json").expanduser()
300+
301+
302+
def load_pricing_file(path: Path = DEFAULT_PRICING_PATH) -> dict[str, tuple[float, float]]:
303+
"""Load model pricing from a JSON file.
304+
305+
File format: {"model_id": [input_cost, output_cost], ...}
306+
Returns dict suitable for set_model_pricing_table().
307+
"""
308+
data = json.loads(path.read_text())
309+
result: dict[str, tuple[float, float]] = {}
310+
for model_id, costs in data.items():
311+
if isinstance(costs, list) and len(costs) == 2:
312+
result[model_id] = (float(costs[0]), float(costs[1]))
313+
return result
314+
315+
316+
def save_pricing_file(
317+
path: Path = DEFAULT_PRICING_PATH,
318+
pricing: dict[str, tuple[float, float]] | None = None,
319+
) -> Path:
320+
"""Save pricing table to a JSON file. Defaults to current active pricing.
321+
322+
Returns the path written to.
323+
"""
324+
if pricing is None:
325+
pricing = get_model_pricing_table()
326+
path.parent.mkdir(parents=True, exist_ok=True)
327+
data = {model: list(costs) for model, costs in sorted(pricing.items())}
328+
path.write_text(json.dumps(data, indent=2) + "\n")
329+
return path

src/pact/cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ def main() -> None:
246246
p_health = subparsers.add_parser("health", help="Check pipeline health (dysmemic pressure detection)")
247247
p_health.add_argument("project_dir", help="Project directory path")
248248

249+
# pricing
250+
p_pricing = subparsers.add_parser("pricing", help="Show or export model pricing table")
251+
p_pricing.add_argument("--export", action="store_true", help="Export pricing to ~/.config/pact/model_pricing.json")
252+
p_pricing.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
253+
249254
args = parser.parse_args()
250255

251256
if args.verbose:
@@ -325,6 +330,35 @@ def main() -> None:
325330
asyncio.run(cmd_adopt(args))
326331
elif args.command == "health":
327332
cmd_health(args)
333+
elif args.command == "pricing":
334+
cmd_pricing(args)
335+
336+
337+
def cmd_pricing(args: argparse.Namespace) -> None:
338+
"""Show or export model pricing table."""
339+
from pact.budget import get_model_pricing_table, save_pricing_file, DEFAULT_PRICING_PATH
340+
341+
pricing = get_model_pricing_table()
342+
343+
if args.export:
344+
path = save_pricing_file()
345+
print(f"Pricing exported to {path}")
346+
print("Edit this file to override default pricing.")
347+
return
348+
349+
if args.json_output:
350+
import json
351+
data = {model: list(costs) for model, costs in sorted(pricing.items())}
352+
print(json.dumps(data, indent=2))
353+
return
354+
355+
print("Model Pricing (per million tokens)")
356+
print(f"{'Model':<40} {'Input':>10} {'Output':>10}")
357+
print("-" * 62)
358+
for model, (inp, out) in sorted(pricing.items()):
359+
print(f"{model:<40} ${inp:>8.2f} ${out:>8.2f}")
360+
print()
361+
print(f"Override file: {DEFAULT_PRICING_PATH}")
328362

329363

330364
def cmd_init(args: argparse.Namespace) -> None:
@@ -905,6 +939,21 @@ def cmd_approve(args: argparse.Namespace) -> None:
905939
answer_sources.append((q, answer, "auto", confidence))
906940

907941
interview.approved = True
942+
943+
# Build audited answers with provenance
944+
from datetime import datetime, timezone
945+
from pact.schemas import AuditedAnswer, AnswerSource
946+
interview.audited_answers = [
947+
AuditedAnswer(
948+
question_id=f"q_{i:03d}",
949+
answer=ans,
950+
source=AnswerSource.USER_INTERACTIVE if src == "user" else AnswerSource.CLI_APPROVE,
951+
confidence=conf,
952+
timestamp=datetime.now(timezone.utc).isoformat(),
953+
)
954+
for i, (_, ans, src, conf) in enumerate(answer_sources)
955+
]
956+
908957
project.save_interview(interview)
909958

910959
# Print answer summary

src/pact/config.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66

77
from __future__ import annotations
88

9+
import logging
910
import os
1011
from dataclasses import dataclass, field
1112
from enum import StrEnum
1213
from pathlib import Path
1314

15+
logger = logging.getLogger(__name__)
16+
1417
import yaml
1518

1619

@@ -248,9 +251,18 @@ def load_global_config(config_path: str | Path | None = None) -> GlobalConfig:
248251
fast=model_tiers_raw.get("fast", config.model_tiers.fast),
249252
)
250253

251-
# Apply pricing overrides if configured
254+
# Load external pricing file if it exists
255+
from pact.budget import DEFAULT_PRICING_PATH, load_pricing_file, set_model_pricing_table
256+
if DEFAULT_PRICING_PATH.exists():
257+
try:
258+
file_overrides = load_pricing_file(DEFAULT_PRICING_PATH)
259+
if file_overrides:
260+
set_model_pricing_table(file_overrides)
261+
except Exception as e:
262+
logger.warning("Failed to load pricing file %s: %s", DEFAULT_PRICING_PATH, e)
263+
264+
# Apply pricing overrides from config (takes precedence over file)
252265
if config.model_pricing:
253-
from pact.budget import set_model_pricing_table
254266
overrides = {
255267
model_id: (costs[0], costs[1])
256268
for model_id, costs in config.model_pricing.items()

src/pact/contracts.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,15 +295,25 @@ def validate_all_contracts(
295295
else:
296296
all_errors.extend(validate_test_suite(test_suites[node_id]))
297297

298+
# Quality audit — non-blocking warnings for vague language
299+
from pact.quality import audit_contract_specificity
300+
quality_warnings = audit_contract_specificity(contract)
301+
for w in quality_warnings:
302+
logger.warning("Quality: %s", w)
303+
298304
# Check dependency contracts — distinguish internal vs external
299305
tree_component_ids = set(tree.nodes.keys()) if tree else set()
300306

301307
for cid, contract in contracts.items():
302308
for dep_id in contract.dependencies:
303-
if dep_id in contracts:
309+
# Apply name normalization before resolution
310+
resolved = normalize_dependency_name(dep_id, list(contracts.keys()))
311+
effective_id = resolved or dep_id
312+
313+
if effective_id in contracts:
304314
continue # Internal dependency with contract — OK
305315

306-
if dep_id in tree_component_ids:
316+
if effective_id in tree_component_ids:
307317
# Internal (in decomposition tree) but missing contract — error
308318
all_errors.append(
309319
f"Contract '{cid}' depends on '{dep_id}' which is in the "

0 commit comments

Comments
 (0)