obey-cli 0.1.0__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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
obey_cli/commands.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""Implementations of the obey subcommands (argument parsing lives in cli.py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from obey_cli import config as cfg
|
|
15
|
+
|
|
16
|
+
DEFAULT_IMAGE = "obey-sandbox:0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def out(text: str) -> None:
|
|
20
|
+
print(text, flush=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def err(text: str) -> None:
|
|
24
|
+
print(f"obey: {text}", file=sys.stderr, flush=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _root(args: argparse.Namespace) -> Path:
|
|
28
|
+
return Path(args.repo).resolve()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]:
|
|
32
|
+
root = _root(args)
|
|
33
|
+
return root, cfg.load(root / args.config)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------------------------- probe
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_probe(args: argparse.Namespace) -> int:
|
|
40
|
+
from obey_cli.probes.facts import detect
|
|
41
|
+
from obey_cli.probes.templates import probes_for
|
|
42
|
+
|
|
43
|
+
root, config = _load(args)
|
|
44
|
+
facts = detect(root)
|
|
45
|
+
added = 0
|
|
46
|
+
for rule in config["rules"]:
|
|
47
|
+
if rule.get("probes") and not args.regenerate:
|
|
48
|
+
continue
|
|
49
|
+
probes = probes_for(rule, facts)
|
|
50
|
+
if probes:
|
|
51
|
+
rule["probes"] = probes
|
|
52
|
+
added += 1
|
|
53
|
+
cfg.save(root / args.config, config)
|
|
54
|
+
active = [r for r in config["rules"] if r["status"] == "active"]
|
|
55
|
+
with_probes = [r for r in active if r.get("probes")]
|
|
56
|
+
out(
|
|
57
|
+
f"probes for {len(with_probes)} of {len(active)} active rules ({added} new); language {facts.language}, "
|
|
58
|
+
f"package manager {facts.package_manager or 'unknown'}, test command {facts.test_command or 'unknown'}"
|
|
59
|
+
)
|
|
60
|
+
for r in active:
|
|
61
|
+
if not r.get("probes"):
|
|
62
|
+
out(f" no template fits: {r['id']} ({r['checker']['type']}); add a probe by hand in {args.config}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------------------------- run
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _key() -> str:
|
|
70
|
+
return os.environ.get("OBEY_API_KEY") or os.environ.get("OPENROUTER_API_KEY") or ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rule_scores_from_report(report: dict[str, Any]) -> list[Any]:
|
|
74
|
+
from obey_cli.score import RuleScore
|
|
75
|
+
|
|
76
|
+
fields = RuleScore.__dataclass_fields__
|
|
77
|
+
return [RuleScore(**{k: v for k, v in s.items() if k in fields}) for s in report.get("scores", [])]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _enforcement_files(rules: list[dict[str, Any]], targets: list[str]) -> tuple[dict[str, str], dict[str, str]]:
|
|
81
|
+
"""Contents of the enforcement files obey would write for `rules`, plus git hook shims."""
|
|
82
|
+
from obey_cli.enforce import plan_enforce
|
|
83
|
+
|
|
84
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
85
|
+
plan = plan_enforce(Path(tmp), rules, targets)
|
|
86
|
+
files = {rel: after for rel, (_, after) in plan.changes.items() if after is not None}
|
|
87
|
+
hooks = {}
|
|
88
|
+
if "precommit" in targets:
|
|
89
|
+
shim = '#!/bin/sh\nexec python3 "$(git rev-parse --show-toplevel)/.obey/pre-commit" "$@"\n'
|
|
90
|
+
hooks = {"pre-commit": shim, "commit-msg": shim}
|
|
91
|
+
return files, hooks
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
95
|
+
from obey_cli.report import badge, build_report
|
|
96
|
+
from obey_cli.runner import agents
|
|
97
|
+
from obey_cli.runner.core import RunSpec, execute
|
|
98
|
+
from obey_cli.runner.ledger import Ledger
|
|
99
|
+
from obey_cli.runner.orchestrate import estimate, execute_all, model_price, outcomes, plan_runs
|
|
100
|
+
from obey_cli.score import score_outcomes
|
|
101
|
+
|
|
102
|
+
root, config = _load(args)
|
|
103
|
+
agent_cfg = (config.get("agents") or [{}])[0]
|
|
104
|
+
agent_name = args.agent or agent_cfg.get("agent") or "claude-code"
|
|
105
|
+
model = args.model or agent_cfg.get("model")
|
|
106
|
+
if not model:
|
|
107
|
+
err(
|
|
108
|
+
"choose a model with --model (an OpenRouter model id such as anthropic/claude-haiku-5.5) "
|
|
109
|
+
"or set agents: [{agent, model}] in obey.yaml"
|
|
110
|
+
)
|
|
111
|
+
return 2
|
|
112
|
+
adapter = agents.get(agent_name)
|
|
113
|
+
rules = [r for r in config["rules"] if r["status"] == "active" and r.get("probes")]
|
|
114
|
+
if args.rules:
|
|
115
|
+
wanted = set(args.rules.split(","))
|
|
116
|
+
rules = [r for r in rules if r["id"] in wanted]
|
|
117
|
+
if args.unsafe_local:
|
|
118
|
+
refused = [r for r in rules if r["checker"]["type"] in ("no_commit", "no_push")]
|
|
119
|
+
rules = [r for r in rules if r not in refused]
|
|
120
|
+
for r in refused:
|
|
121
|
+
err(f"--unsafe-local refuses {r['checker']['type']} probes: skipping {r['id']}")
|
|
122
|
+
if not rules:
|
|
123
|
+
err("no active rules with probes; run `obey extract` and `obey probe` first")
|
|
124
|
+
return 1
|
|
125
|
+
before = None
|
|
126
|
+
enforced_files: dict[str, str] | None = None
|
|
127
|
+
git_hooks: dict[str, str] = {}
|
|
128
|
+
if args.compare:
|
|
129
|
+
from obey_cli.enforce import select
|
|
130
|
+
|
|
131
|
+
before = json.loads(Path(args.compare).read_text(encoding="utf-8"))
|
|
132
|
+
broken = select(config, report=before)
|
|
133
|
+
rules = [r for r in rules if r["id"] in {b["id"] for b in broken}]
|
|
134
|
+
if not rules:
|
|
135
|
+
out("Nothing to compare: no rule was broken in the BEFORE report.")
|
|
136
|
+
return 0
|
|
137
|
+
enforced_files, git_hooks = _enforcement_files(broken, args.targets.split(","))
|
|
138
|
+
planned = plan_runs(
|
|
139
|
+
root,
|
|
140
|
+
rules,
|
|
141
|
+
trials=args.trials,
|
|
142
|
+
baseline_trials=0 if args.no_baseline or args.compare else args.baseline_trials,
|
|
143
|
+
enforced=enforced_files,
|
|
144
|
+
enforced_hooks=git_hooks,
|
|
145
|
+
only_enforced=bool(args.compare),
|
|
146
|
+
)
|
|
147
|
+
price = model_price(model) if not args.offline_price else {}
|
|
148
|
+
est = estimate(planned, agent_name, price)
|
|
149
|
+
out(f"{est['runs']} runs × ~${est['per_run_usd']:.4f} ≈ ${est['total_usd']:.2f} ({est['source']}; model {model})")
|
|
150
|
+
if args.estimate:
|
|
151
|
+
return 0
|
|
152
|
+
key = _key()
|
|
153
|
+
if not key:
|
|
154
|
+
err("set OPENROUTER_API_KEY (or OBEY_API_KEY) to run agents; the key stays outside the sandbox")
|
|
155
|
+
return 2
|
|
156
|
+
ledger = Ledger(root / ".obey" / "ledger.jsonl", budget_usd=args.budget)
|
|
157
|
+
if args.unsafe_local:
|
|
158
|
+
from obey_cli.runner.local import WARNING, LocalSandbox
|
|
159
|
+
|
|
160
|
+
err(WARNING)
|
|
161
|
+
box: Any = LocalSandbox()
|
|
162
|
+
else:
|
|
163
|
+
from obey_cli.runner.sandbox import DockerSandbox, docker_available
|
|
164
|
+
|
|
165
|
+
if not docker_available():
|
|
166
|
+
err("Docker is not available. Start Docker (or colima), or use --unsafe-local (read the README first).")
|
|
167
|
+
return 2
|
|
168
|
+
image = args.image or (config.get("sandbox") or {}).get("image") or DEFAULT_IMAGE
|
|
169
|
+
import subprocess
|
|
170
|
+
|
|
171
|
+
from obey_cli.runner.sandbox import docker_cmd
|
|
172
|
+
|
|
173
|
+
if subprocess.run([*docker_cmd(), "image", "inspect", image], capture_output=True, check=False).returncode:
|
|
174
|
+
err(
|
|
175
|
+
f"sandbox image {image} not found. Build it once with `obey doctor --build-image` (a few GB: Python, "
|
|
176
|
+
"Node, Go and the agent CLIs from npm), or set sandbox.image in obey.yaml."
|
|
177
|
+
)
|
|
178
|
+
return 2
|
|
179
|
+
box = DockerSandbox(image)
|
|
180
|
+
allow = list((config.get("sandbox") or {}).get("allow_hosts") or cfg.DEFAULT_ALLOW_HOSTS)
|
|
181
|
+
remaining = ledger.remaining()
|
|
182
|
+
batch = box.start_batch(
|
|
183
|
+
key=key,
|
|
184
|
+
model=model,
|
|
185
|
+
price=price,
|
|
186
|
+
allow_hosts=allow,
|
|
187
|
+
run_cap=args.run_cap,
|
|
188
|
+
budget=remaining,
|
|
189
|
+
run_ids=[p.run_id for p in planned],
|
|
190
|
+
)
|
|
191
|
+
stamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
192
|
+
out_dir = Path(args.out) if args.out else root / ".obey" / "runs" / stamp
|
|
193
|
+
runs_file = out_dir / "runs.jsonl"
|
|
194
|
+
timeout = int((config.get("sandbox") or {}).get("timeout_s") or 600)
|
|
195
|
+
|
|
196
|
+
def run_one(run: Any) -> dict[str, Any]:
|
|
197
|
+
spec = RunSpec(
|
|
198
|
+
run_id=run.run_id,
|
|
199
|
+
prompt=run.probe["instruction"],
|
|
200
|
+
source=root,
|
|
201
|
+
probe_id=run.probe["id"],
|
|
202
|
+
rule_id=run.rule["id"],
|
|
203
|
+
arm=run.arm,
|
|
204
|
+
trial=run.trial,
|
|
205
|
+
overrides=run.overrides,
|
|
206
|
+
extra_files=run.extra_files,
|
|
207
|
+
git_hooks=run.git_hooks,
|
|
208
|
+
setup_patch=run.probe.get("setup_patch"),
|
|
209
|
+
timeout_s=timeout,
|
|
210
|
+
)
|
|
211
|
+
return execute(adapter, spec, box=box, batch=batch, model=model)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
result = execute_all(
|
|
215
|
+
planned,
|
|
216
|
+
run_one,
|
|
217
|
+
ledger=ledger,
|
|
218
|
+
per_run_estimate=est["per_run_usd"],
|
|
219
|
+
concurrency=args.concurrency,
|
|
220
|
+
out=runs_file,
|
|
221
|
+
progress=out,
|
|
222
|
+
)
|
|
223
|
+
finally:
|
|
224
|
+
box.stop_batch(batch)
|
|
225
|
+
if result.stopped:
|
|
226
|
+
err(f"stopped early: {result.stopped}")
|
|
227
|
+
by_id = {r["id"]: r for r in config["rules"]}
|
|
228
|
+
scores = score_outcomes(outcomes(result.records, by_id))
|
|
229
|
+
versions = {r.get("agent_version") for r in result.records if r.get("agent_version")}
|
|
230
|
+
cost = sum(r["usage"]["cost_usd"] for r in result.records)
|
|
231
|
+
report = build_report(
|
|
232
|
+
config["rules"],
|
|
233
|
+
scores,
|
|
234
|
+
[{"agent": agent_name, "version": next(iter(versions), None), "model": model}],
|
|
235
|
+
repository=root.name,
|
|
236
|
+
cost_usd=cost,
|
|
237
|
+
runs=len(result.records),
|
|
238
|
+
before=_rule_scores_from_report(before) if before is not None else None,
|
|
239
|
+
)
|
|
240
|
+
_write_reports(root, out_dir, report)
|
|
241
|
+
for line in report["summary"]:
|
|
242
|
+
out(line + ".")
|
|
243
|
+
out(f"Report: {out_dir / 'report.html'} (cost ${cost:.2f} for {len(result.records)} runs)")
|
|
244
|
+
(root / "obey-badge.json").write_text(json.dumps(badge(report)) + "\n", encoding="utf-8")
|
|
245
|
+
return 0 if not result.stopped else 3
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_reports(root: Path, out_dir: Path, report: dict[str, Any]) -> None:
|
|
249
|
+
import jsonschema
|
|
250
|
+
|
|
251
|
+
from obey_cli.report import render_html, render_markdown
|
|
252
|
+
from obey_cli.resources import schema
|
|
253
|
+
|
|
254
|
+
jsonschema.validate(report, schema("report-v1.json"))
|
|
255
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
(out_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
257
|
+
(out_dir / "report.md").write_text(render_markdown(report), encoding="utf-8")
|
|
258
|
+
(out_dir / "report.html").write_text(render_html(report), encoding="utf-8")
|
|
259
|
+
latest = root / ".obey"
|
|
260
|
+
latest.mkdir(exist_ok=True)
|
|
261
|
+
(latest / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------------------------- report
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
268
|
+
"""Re-score recorded runs with the current checkers and render the report again."""
|
|
269
|
+
from obey_cli.report import build_report, render_html, render_markdown
|
|
270
|
+
from obey_cli.runner.orchestrate import outcomes
|
|
271
|
+
from obey_cli.score import score_outcomes
|
|
272
|
+
|
|
273
|
+
root, config = _load(args)
|
|
274
|
+
runs_path = Path(args.runs)
|
|
275
|
+
records = [json.loads(line) for line in runs_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
276
|
+
by_id = {r["id"]: r for r in config["rules"]}
|
|
277
|
+
scores = score_outcomes(outcomes(records, by_id))
|
|
278
|
+
agents_seen = sorted({(r["agent"], r.get("agent_version"), r.get("model")) for r in records}, key=lambda x: str(x))
|
|
279
|
+
report = build_report(
|
|
280
|
+
config["rules"],
|
|
281
|
+
scores,
|
|
282
|
+
[{"agent": a, "version": v, "model": m} for a, v, m in agents_seen],
|
|
283
|
+
repository=root.name,
|
|
284
|
+
cost_usd=sum(r["usage"]["cost_usd"] for r in records),
|
|
285
|
+
runs=len(records),
|
|
286
|
+
)
|
|
287
|
+
fmt = args.format
|
|
288
|
+
if fmt == "json":
|
|
289
|
+
out(json.dumps(report, indent=2))
|
|
290
|
+
elif fmt == "md":
|
|
291
|
+
out(render_markdown(report))
|
|
292
|
+
elif fmt == "html":
|
|
293
|
+
out(render_html(report))
|
|
294
|
+
else:
|
|
295
|
+
_write_reports(root, runs_path.parent, report)
|
|
296
|
+
out(f"wrote {runs_path.parent / 'report.html'}, report.md and report.json")
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------------------------- enforce
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_enforce(args: argparse.Namespace) -> int:
|
|
304
|
+
from obey_cli.enforce import advisory, plan_enforce, plan_remove, select
|
|
305
|
+
from obey_cli.enforce.targets import TARGETS
|
|
306
|
+
|
|
307
|
+
root = _root(args)
|
|
308
|
+
if args.remove:
|
|
309
|
+
plan = plan_remove(root)
|
|
310
|
+
else:
|
|
311
|
+
config = cfg.load(root / args.config)
|
|
312
|
+
report = json.loads(Path(args.from_report).read_text(encoding="utf-8")) if args.from_report else None
|
|
313
|
+
ids = args.rules.split(",") if args.rules else None
|
|
314
|
+
rules = select(config, report=report, ids=ids)
|
|
315
|
+
targets = [t.strip() for t in args.targets.split(",") if t.strip()]
|
|
316
|
+
unknown = [t for t in targets if t not in TARGETS]
|
|
317
|
+
if unknown:
|
|
318
|
+
err(f"unknown target(s): {', '.join(unknown)}; choose from {', '.join(TARGETS)}")
|
|
319
|
+
return 2
|
|
320
|
+
if not rules:
|
|
321
|
+
out(
|
|
322
|
+
"No rules to enforce (no active rules with a mechanical checker"
|
|
323
|
+
+ (", or none broken in the report)." if report else ").")
|
|
324
|
+
)
|
|
325
|
+
return 0
|
|
326
|
+
plan = plan_enforce(root, rules, targets)
|
|
327
|
+
for item in advisory(rules, targets):
|
|
328
|
+
out(f"advisory only: {item['rule_id']} on {item['target']} ({item['reason']})")
|
|
329
|
+
if args.dry_run:
|
|
330
|
+
diff = plan.diff()
|
|
331
|
+
out(diff if diff else "No changes.")
|
|
332
|
+
return 0
|
|
333
|
+
written = plan.apply()
|
|
334
|
+
out("No changes." if not written else "Updated: " + ", ".join(written))
|
|
335
|
+
if not args.remove and written and "precommit" in args.targets:
|
|
336
|
+
out(
|
|
337
|
+
"Git hook: ln -s ../../.obey/pre-commit .git/hooks/pre-commit && "
|
|
338
|
+
"ln -s ../../.obey/pre-commit .git/hooks/commit-msg (or use the pre-commit framework; see the README)"
|
|
339
|
+
)
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_precommit(args: argparse.Namespace) -> int:
|
|
344
|
+
"""Entry point for the pre-commit framework: runs .obey/pre-commit (generated by `obey enforce`)."""
|
|
345
|
+
import runpy
|
|
346
|
+
|
|
347
|
+
root = _root(args)
|
|
348
|
+
script = root / ".obey" / "pre-commit"
|
|
349
|
+
if not script.exists():
|
|
350
|
+
err("no .obey/pre-commit here; run `obey enforce --targets precommit` first")
|
|
351
|
+
return 1
|
|
352
|
+
argv = [str(script), *args.files[-1:]] if args.commit_msg and args.files else [str(script)]
|
|
353
|
+
sys.argv = argv
|
|
354
|
+
try:
|
|
355
|
+
runpy.run_path(str(script), run_name="__main__")
|
|
356
|
+
except SystemExit as exc:
|
|
357
|
+
return int(exc.code or 0)
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --------------------------------------------------------------------------------------------- lint
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_lint(args: argparse.Namespace) -> int:
|
|
365
|
+
from obey_cli.lint import lint
|
|
366
|
+
|
|
367
|
+
root = _root(args)
|
|
368
|
+
result = lint(root, args.config, write_badge=not args.no_badge)
|
|
369
|
+
for line in result.lines:
|
|
370
|
+
out(line)
|
|
371
|
+
return 0 if result.ok else 1
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# --------------------------------------------------------------------------------------------- doctor
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
378
|
+
import platform
|
|
379
|
+
import shutil
|
|
380
|
+
import subprocess
|
|
381
|
+
|
|
382
|
+
from obey_cli import __version__
|
|
383
|
+
from obey_cli.runner.sandbox import docker_available, docker_cmd
|
|
384
|
+
|
|
385
|
+
rows = [("obey", __version__), ("python", platform.python_version())]
|
|
386
|
+
rows.append(("git", "yes" if shutil.which("git") else "missing (required)"))
|
|
387
|
+
docker = docker_available()
|
|
388
|
+
rows.append(
|
|
389
|
+
(
|
|
390
|
+
"docker",
|
|
391
|
+
"available"
|
|
392
|
+
if docker
|
|
393
|
+
else f"not available ({' '.join(docker_cmd())}); use colima or Docker Desktop, or --unsafe-local",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
image = args.image or DEFAULT_IMAGE
|
|
397
|
+
if docker:
|
|
398
|
+
probe = subprocess.run([*docker_cmd(), "image", "inspect", image], capture_output=True, check=False)
|
|
399
|
+
rows.append(
|
|
400
|
+
("sandbox image", image if probe.returncode == 0 else f"{image} missing (obey doctor --build-image)")
|
|
401
|
+
)
|
|
402
|
+
if args.build_image:
|
|
403
|
+
from obey_cli.resources import data_path
|
|
404
|
+
|
|
405
|
+
context = Path(__file__).parent / "runner" / "sandbox"
|
|
406
|
+
out(f"building {image} from {context} (a few GB) ...")
|
|
407
|
+
code = subprocess.run([*docker_cmd(), "build", "-t", image, str(context)], check=False).returncode
|
|
408
|
+
rows.append(("build", "ok" if code == 0 else f"failed ({code})"))
|
|
409
|
+
_ = data_path
|
|
410
|
+
elif probe.returncode == 0:
|
|
411
|
+
versions = subprocess.run(
|
|
412
|
+
[
|
|
413
|
+
*docker_cmd(),
|
|
414
|
+
"run",
|
|
415
|
+
"--rm",
|
|
416
|
+
"--entrypoint",
|
|
417
|
+
"sh",
|
|
418
|
+
image,
|
|
419
|
+
"-c",
|
|
420
|
+
"claude --version; codex --version; opencode --version; copilot --version; gemini --version",
|
|
421
|
+
],
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
check=False,
|
|
425
|
+
timeout=120,
|
|
426
|
+
).stdout
|
|
427
|
+
for line in versions.strip().splitlines():
|
|
428
|
+
rows.append((" agent", line.strip()))
|
|
429
|
+
key = _key()
|
|
430
|
+
rows.append(
|
|
431
|
+
("API key", "set (kept outside the sandbox)" if key else "not set (OPENROUTER_API_KEY or OBEY_API_KEY)")
|
|
432
|
+
)
|
|
433
|
+
root = _root(args)
|
|
434
|
+
rows.append(("obey.yaml", "found" if (root / args.config).exists() else "missing (obey extract)"))
|
|
435
|
+
width = max(len(k) for k, _ in rows)
|
|
436
|
+
for k, v in rows:
|
|
437
|
+
out(f"{k.ljust(width)} {v}")
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --------------------------------------------------------------------------------------------- init
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
445
|
+
"""extract + probe, then print what to do next."""
|
|
446
|
+
code = cmd_extract_from_commands(args)
|
|
447
|
+
if code != 0:
|
|
448
|
+
return code
|
|
449
|
+
cmd_probe(argparse.Namespace(**{**vars(args), "regenerate": False}))
|
|
450
|
+
out("")
|
|
451
|
+
out("Next: review obey.yaml, then")
|
|
452
|
+
out(" obey run --estimate --model anthropic/claude-haiku-5.5 # how many runs, roughly what it costs")
|
|
453
|
+
out(" obey run --model anthropic/claude-haiku-5.5 # needs Docker and OPENROUTER_API_KEY")
|
|
454
|
+
out(" obey enforce --from .obey/report.json # hooks for the rules your agent broke")
|
|
455
|
+
out(" obey lint # in CI, no agent runs")
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def cmd_extract_from_commands(args: argparse.Namespace) -> int:
|
|
460
|
+
from obey_cli.cli import cmd_extract
|
|
461
|
+
|
|
462
|
+
defaults = {"llm": False, "llm_model": None, "budget": 1.0, "include_local": False, "dry_run": False}
|
|
463
|
+
return cmd_extract(argparse.Namespace(**{**defaults, **vars(args)}))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# --------------------------------------------------------------------------------------------- study
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_study(args: argparse.Namespace) -> int:
|
|
470
|
+
root = _root(args)
|
|
471
|
+
action = args.study_command
|
|
472
|
+
if action == "survey":
|
|
473
|
+
return _study_survey(args, root)
|
|
474
|
+
if action == "run":
|
|
475
|
+
return _study_run(args, root)
|
|
476
|
+
if action == "plan":
|
|
477
|
+
return _study_plan(args, root)
|
|
478
|
+
if action == "report":
|
|
479
|
+
from obey_cli.study.report import write_report
|
|
480
|
+
|
|
481
|
+
out(write_report(root))
|
|
482
|
+
return 0
|
|
483
|
+
err("choose one of: survey, run, plan, report")
|
|
484
|
+
return 2
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _study_survey(args: argparse.Namespace, root: Path) -> int:
|
|
488
|
+
from obey_cli.study import survey
|
|
489
|
+
|
|
490
|
+
folder = root / "study" / "survey"
|
|
491
|
+
sample = folder / "sample.jsonl"
|
|
492
|
+
if args.collect:
|
|
493
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
494
|
+
if not token:
|
|
495
|
+
err("set GITHUB_TOKEN for GitHub code search")
|
|
496
|
+
return 2
|
|
497
|
+
survey.collect(token, sample, target=args.target, log=out)
|
|
498
|
+
if args.analyse:
|
|
499
|
+
survey.analyse(sample, folder / "features.jsonl", log=out)
|
|
500
|
+
if args.llm:
|
|
501
|
+
from obey_cli.llm import LLMConfig
|
|
502
|
+
from obey_cli.runner.ledger import Ledger
|
|
503
|
+
|
|
504
|
+
ledger = Ledger(root / ".obey" / "survey-ledger.jsonl", budget_usd=args.budget)
|
|
505
|
+
survey.llm_pass(sample, folder / "llm.jsonl", LLMConfig.from_env(args.llm_model), ledger, log=out)
|
|
506
|
+
summary = survey.summarise(folder / "features.jsonl", folder / "llm.jsonl", sample)
|
|
507
|
+
(folder / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
|
508
|
+
out(json.dumps({k: v for k, v in summary.items() if k not in ("types", "licences")}, indent=2))
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _study_run(args: argparse.Namespace, root: Path) -> int:
|
|
513
|
+
from obey_cli.study import design as d
|
|
514
|
+
from obey_cli.study import experiment
|
|
515
|
+
|
|
516
|
+
design = d.load(root / "study" / "design.yaml")
|
|
517
|
+
key = _key()
|
|
518
|
+
filters = {
|
|
519
|
+
k: v.split(",") for k, v in (("repos", args.repos), ("slots", args.slots), ("harnesses", args.harnesses)) if v
|
|
520
|
+
}
|
|
521
|
+
phase = args.phase
|
|
522
|
+
if args.phase == "enforce":
|
|
523
|
+
# Cells whose `--from-condition` runs were violated at least once in the main grid are re-run in that same
|
|
524
|
+
# condition with hooks. The pre-registered phase uses the imperative condition; `baseline` measures hooks
|
|
525
|
+
# without the rule in AGENTS.md (the hook's refusal still quotes the rule).
|
|
526
|
+
cond = args.from_condition
|
|
527
|
+
phase = "enforce" if cond == "imperative" else f"enforce-{cond}"
|
|
528
|
+
verdicts = Path(args.verdicts) if args.verdicts else root / "study" / "results" / "main.jsonl"
|
|
529
|
+
rows = [json.loads(line) for line in verdicts.read_text().splitlines() if line.strip()]
|
|
530
|
+
broken = sorted(
|
|
531
|
+
{
|
|
532
|
+
(r["repo"], r["slot"], r["harness"])
|
|
533
|
+
for r in rows
|
|
534
|
+
if r["status"] == "ok" and r["condition"] == cond and r["verdict"] == "violated"
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
out(f"{phase}: {len(broken)} slot × repository × harness cells were violated in the {cond} condition")
|
|
538
|
+
runs = [
|
|
539
|
+
experiment.StudyRun(phase, d.Cell(repo, slot, cond, harness), t)
|
|
540
|
+
for repo, slot, harness in broken
|
|
541
|
+
for t in range(args.trials)
|
|
542
|
+
]
|
|
543
|
+
else:
|
|
544
|
+
runs = experiment.plan(design, args.phase, args.trials, **filters)
|
|
545
|
+
if args.estimate:
|
|
546
|
+
out(f"{len(runs)} runs planned for phase {phase}")
|
|
547
|
+
return 0
|
|
548
|
+
if not key:
|
|
549
|
+
err("set OPENROUTER_API_KEY")
|
|
550
|
+
return 2
|
|
551
|
+
result = experiment.execute(
|
|
552
|
+
design,
|
|
553
|
+
runs,
|
|
554
|
+
root=root,
|
|
555
|
+
key=key,
|
|
556
|
+
image=args.image or DEFAULT_IMAGE,
|
|
557
|
+
budget=args.budget,
|
|
558
|
+
spent_before=0.0,
|
|
559
|
+
concurrency=args.concurrency,
|
|
560
|
+
results=root / "study" / "results",
|
|
561
|
+
enforce_rules=args.phase == "enforce",
|
|
562
|
+
progress=out,
|
|
563
|
+
)
|
|
564
|
+
out(json.dumps(result))
|
|
565
|
+
return 0 if not result["stopped"] else 3
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _study_plan(args: argparse.Namespace, root: Path) -> int:
|
|
569
|
+
from obey_cli.study.analysis import trials_needed
|
|
570
|
+
|
|
571
|
+
rows = [
|
|
572
|
+
json.loads(line)
|
|
573
|
+
for line in (root / "study" / "results" / "pilot.jsonl").read_text().splitlines()
|
|
574
|
+
if line.strip()
|
|
575
|
+
]
|
|
576
|
+
plan = trials_needed(rows)
|
|
577
|
+
ok = [r for r in rows if r["status"] == "ok"]
|
|
578
|
+
plan["pilot_runs"] = len(rows)
|
|
579
|
+
plan["pilot_cost_usd"] = sum(r["cost_usd"] for r in rows)
|
|
580
|
+
plan["cost_per_run_usd"] = plan["pilot_cost_usd"] / len(ok) if ok else None
|
|
581
|
+
plan["main_runs"] = plan["trials_per_cell"] * 160
|
|
582
|
+
plan["main_cost_usd"] = plan["main_runs"] * (plan["cost_per_run_usd"] or 0)
|
|
583
|
+
(root / "study" / "results" / "plan.json").write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
|
|
584
|
+
out(json.dumps(plan, indent=2))
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# --------------------------------------------------------------------------------------------- demo
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _read_runs(path: Path) -> list[dict[str, Any]]:
|
|
592
|
+
import gzip
|
|
593
|
+
|
|
594
|
+
if not path.exists():
|
|
595
|
+
return []
|
|
596
|
+
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
|
597
|
+
return [json.loads(line) for line in fh if line.strip()]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _cut(text: str, width: int) -> str:
|
|
601
|
+
text = " ".join(str(text).split())
|
|
602
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def cmd_demo(args: argparse.Namespace) -> int:
|
|
606
|
+
"""Replay recorded study runs on the bundled demo repository: no API key, no Docker."""
|
|
607
|
+
from obey_cli.report import build_report, render_html
|
|
608
|
+
from obey_cli.resources import data_path
|
|
609
|
+
from obey_cli.runner.orchestrate import outcomes
|
|
610
|
+
from obey_cli.score import AGENT_NAMES, score_outcomes
|
|
611
|
+
|
|
612
|
+
demo = data_path("demo")
|
|
613
|
+
config = cfg.load(demo / "obey.yaml")
|
|
614
|
+
rules = {r["id"]: r for r in config["rules"]}
|
|
615
|
+
before_runs = _read_runs(demo / "runs" / "before.jsonl.gz")
|
|
616
|
+
after_runs = _read_runs(demo / "runs" / "after.jsonl.gz")
|
|
617
|
+
before = score_outcomes(outcomes(before_runs, rules))
|
|
618
|
+
after = score_outcomes(outcomes(after_runs, rules))
|
|
619
|
+
meta = json.loads((demo / "runs" / "meta.json").read_text(encoding="utf-8"))
|
|
620
|
+
report = build_report(
|
|
621
|
+
config["rules"],
|
|
622
|
+
before + after,
|
|
623
|
+
[{"agent": meta["agent"], "version": meta["agent_version"], "model": meta["model"]}],
|
|
624
|
+
repository=meta["repository"],
|
|
625
|
+
cost_usd=meta["cost_usd"],
|
|
626
|
+
runs=meta["runs"],
|
|
627
|
+
before=before,
|
|
628
|
+
before_arm=meta.get("compare_with", "rule"),
|
|
629
|
+
)
|
|
630
|
+
name = AGENT_NAMES.get(meta["agent"], meta["agent"])
|
|
631
|
+
out(
|
|
632
|
+
f"obey demo: {len(before_runs) + len(after_runs)} recorded runs of {name} {meta['agent_version']} with "
|
|
633
|
+
f"{meta['model']} on {meta['repository']} ({meta['dates']}), replayed and re-scored now."
|
|
634
|
+
)
|
|
635
|
+
out("Probe compliance: obeyed ÷ exercised runs on tempting tasks, Wilson 95% interval.")
|
|
636
|
+
out("")
|
|
637
|
+
out(f"{'Rule (AGENTS.md)':58s} {'Compliance':>17s} {'No rule':>9s} {'Exercised':>9s}")
|
|
638
|
+
base = {s.rule_id: s for s in before if s.arm == "baseline"}
|
|
639
|
+
for s in sorted((s for s in before if s.arm == "rule"), key=lambda s: (s.compliance is None, s.compliance or 0)):
|
|
640
|
+
rule = rules[s.rule_id]
|
|
641
|
+
comp = (
|
|
642
|
+
"never exercised"
|
|
643
|
+
if s.compliance is None
|
|
644
|
+
else (f"{s.compliance * 100:3.0f}% [{(s.ci_low or 0) * 100:2.0f}–{(s.ci_high or 0) * 100:3.0f}]")
|
|
645
|
+
)
|
|
646
|
+
b = base.get(s.rule_id)
|
|
647
|
+
baseline = f"{b.compliance * 100:.0f}%" if b and b.compliance is not None else "–"
|
|
648
|
+
out(f"{_cut(rule['text'], 58):58s} {comp:>17s} {baseline:>9s} {s.exercised:>4d}/{s.runs:<4d}")
|
|
649
|
+
out("")
|
|
650
|
+
for line in report["summary"]:
|
|
651
|
+
out(line + ".")
|
|
652
|
+
cmp = report.get("compare") or {}
|
|
653
|
+
rows = [r for r in cmp.get("rows", []) if r["after"] is not None]
|
|
654
|
+
if rows:
|
|
655
|
+
out("")
|
|
656
|
+
if meta.get("compare_with") == "baseline":
|
|
657
|
+
out("With hooks from `obey enforce` and the rule left out of AGENTS.md (same probes, same agent):")
|
|
658
|
+
out(f"{'Rule':58s} {'No rule':>7s} {'+hooks':>7s} {'Blocked':>8s} {'Evasions':>9s}")
|
|
659
|
+
else:
|
|
660
|
+
out("With hooks from `obey enforce` (same probes, same agent):")
|
|
661
|
+
out(f"{'Rule':58s} {'Before':>7s} {'After':>7s} {'Blocked':>8s} {'Evasions':>9s}")
|
|
662
|
+
for r in rows:
|
|
663
|
+
before_pct = r["before"] * 100 if r["before"] is not None else 0.0
|
|
664
|
+
text = _cut(rules[r["rule_id"]]["text"], 58)
|
|
665
|
+
out(f"{text:58s} {before_pct:6.0f}% {r['after'] * 100:6.0f}% {r['blocked_runs']:8d} {r['evasions']:9d}")
|
|
666
|
+
a, b = cmp["after"], cmp["before"]
|
|
667
|
+
out(
|
|
668
|
+
f"Pooled: {(b['compliance'] or 0) * 100:.0f}% → {(a['compliance'] or 0) * 100:.0f}%; "
|
|
669
|
+
f"{a['evasions']} of {a['blocked_runs']} blocked runs still broke the rule another way."
|
|
670
|
+
)
|
|
671
|
+
if args.html:
|
|
672
|
+
Path(args.html).write_text(render_html(report), encoding="utf-8")
|
|
673
|
+
out(f"HTML report: {args.html}")
|
|
674
|
+
out("")
|
|
675
|
+
out("Your own rules: cd your-repo && obey init && obey run --estimate --model anthropic/claude-haiku-5.5")
|
|
676
|
+
return 0
|