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.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. 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