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
@@ -0,0 +1,198 @@
1
+ """Run the phrasing experiment (pilot, main grid, enforcement) and keep per-run summaries.
2
+
3
+ Per-run summaries go to study/results/<phase>.jsonl (committed). Full run records with streams-derived events and
4
+ diffs go to study/results/raw/<phase>.jsonl (published as a release archive after a scan, never committed).
5
+ Interrupted batches resume: runs whose key is already in the summaries are skipped.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable, Sequence
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from obey_cli.study import design as d
21
+
22
+ HARNESS_TARGET = {
23
+ "claude-code": "claude",
24
+ "opencode": "opencode",
25
+ "codex": "codex",
26
+ "copilot": "copilot",
27
+ "gemini": "gemini",
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class StudyRun:
33
+ phase: str
34
+ cell: d.Cell
35
+ trial: int
36
+
37
+ @property
38
+ def key(self) -> str:
39
+ c = self.cell
40
+ return f"{self.phase}|{c.repo}|{c.slot}|{c.condition}|{c.harness}|{self.trial}"
41
+
42
+ @property
43
+ def run_id(self) -> str:
44
+ return f"{self.phase}-{hashlib.sha1(self.key.encode()).hexdigest()[:12]}" # noqa: S324 - an identifier
45
+
46
+
47
+ def plan(design: dict[str, Any], phase: str, trials: int, **filters: Any) -> list[StudyRun]:
48
+ runs = [StudyRun(phase, cell, t) for cell in d.cells(design, **filters) for t in range(trials)]
49
+ # Interleave cells so an early stop leaves a balanced sample rather than whole missing conditions.
50
+ runs.sort(key=lambda r: (r.trial, hashlib.sha1(r.key.encode()).hexdigest())) # noqa: S324
51
+ return runs
52
+
53
+
54
+ def done_keys(summaries: Path) -> set[str]:
55
+ if not summaries.exists():
56
+ return set()
57
+ keys = set()
58
+ for line in summaries.read_text(encoding="utf-8").splitlines():
59
+ if line.strip():
60
+ row = json.loads(line)
61
+ if row.get("status") == "ok":
62
+ keys.add(row["key"])
63
+ return keys
64
+
65
+
66
+ def summarize(run: StudyRun, record: dict[str, Any], verdict: Any) -> dict[str, Any]:
67
+ denied = sum(1 for h in record.get("hook_log") or [] if h.get("decision") in ("deny", "block"))
68
+ return {
69
+ "key": run.key,
70
+ "run_id": record["run_id"],
71
+ "phase": run.phase,
72
+ "repo": run.cell.repo,
73
+ "slot": run.cell.slot,
74
+ "condition": run.cell.condition,
75
+ "harness": run.cell.harness,
76
+ "trial": run.trial,
77
+ "agent_version": record.get("agent_version"),
78
+ "model": record.get("model"),
79
+ "status": record["status"],
80
+ "verdict": verdict.verdict,
81
+ "evidence_kind": (verdict.evidence[0]["kind"] if verdict.evidence else None),
82
+ "denied": denied,
83
+ "cost_usd": record["usage"]["cost_usd"],
84
+ "input_tokens": record["usage"]["input_tokens"],
85
+ "output_tokens": record["usage"]["output_tokens"],
86
+ "duration_s": record["duration_s"],
87
+ "events": len(record.get("events") or []),
88
+ "commits": len(record.get("commits") or []),
89
+ "started_at": record.get("started_at"),
90
+ }
91
+
92
+
93
+ def execute(
94
+ design: dict[str, Any],
95
+ runs: Sequence[StudyRun],
96
+ *,
97
+ root: Path,
98
+ key: str,
99
+ image: str,
100
+ budget: float,
101
+ spent_before: float,
102
+ concurrency: int,
103
+ results: Path,
104
+ enforce_rules: bool = False,
105
+ progress: Callable[[str], None] = print,
106
+ ) -> dict[str, Any]:
107
+ """Run `runs` grouped by harness (one gateway batch each), appending summaries and raw records as they finish."""
108
+ from obey_cli.checkers import check
109
+ from obey_cli.commands import _enforcement_files
110
+ from obey_cli.runner import agents
111
+ from obey_cli.runner.core import RunSpec
112
+ from obey_cli.runner.core import execute as execute_run
113
+ from obey_cli.runner.sandbox import DockerSandbox
114
+
115
+ phase = runs[0].phase if runs else "none"
116
+ summaries = results / f"{phase}.jsonl"
117
+ raw = results / "raw" / f"{phase}.jsonl"
118
+ raw.parent.mkdir(parents=True, exist_ok=True)
119
+ skip = done_keys(summaries)
120
+ todo = [r for r in runs if r.key not in skip]
121
+ progress(f"{phase}: {len(todo)} runs to do ({len(runs) - len(todo)} already done)")
122
+ box = DockerSandbox(image, cpus="2", memory="4g")
123
+ lock = threading.Lock()
124
+ spent = [spent_before]
125
+ stopped: list[str] = []
126
+ started = time.monotonic()
127
+ model = design["model"]
128
+ for harness in sorted({r.cell.harness for r in todo}):
129
+ adapter = agents.get(harness)
130
+ mine = [r for r in todo if r.cell.harness == harness]
131
+ batch = box.start_batch(
132
+ key=key,
133
+ model=model,
134
+ price={},
135
+ allow_hosts=design.get("allow_hosts") or ["registry.npmjs.org", "pypi.org", "files.pythonhosted.org"],
136
+ run_cap=0.5,
137
+ budget=None,
138
+ run_ids=[r.run_id for r in mine],
139
+ )
140
+
141
+ def one(run: StudyRun, adapter: Any = adapter, batch: Any = batch, harness: str = harness) -> None:
142
+ if stopped:
143
+ return
144
+ with lock:
145
+ if spent[0] >= budget:
146
+ stopped.append(f"budget ${budget:.2f} reached (spent ${spent[0]:.2f})")
147
+ return
148
+ repo = design["repos"][run.cell.repo]
149
+ text, _ = d.render(repo, run.cell.slot, run.cell.condition)
150
+ rule = d.rule(design, run.cell.repo, run.cell.slot, run.cell.condition)
151
+ extra: dict[str, str] = {}
152
+ hooks: dict[str, str] = {}
153
+ if enforce_rules:
154
+ extra, hooks = _enforcement_files([rule], [HARNESS_TARGET[harness], "precommit"])
155
+ spec = RunSpec(
156
+ run_id=run.run_id,
157
+ prompt=rule["probes"][0]["instruction"],
158
+ source=root / repo["path"],
159
+ probe_id=rule["probes"][0]["id"],
160
+ rule_id=rule["id"],
161
+ arm="enforced" if enforce_rules else ("baseline" if run.cell.condition == "baseline" else "rule"),
162
+ condition=run.cell.condition,
163
+ trial=run.trial,
164
+ overrides={"AGENTS.md": text},
165
+ extra_files=extra,
166
+ git_hooks=hooks,
167
+ timeout_s=600,
168
+ )
169
+ record = execute_run(adapter, spec, box=box, batch=batch, model=model)
170
+ verdict = check(rule["checker"], record)
171
+ summary = summarize(run, record, verdict)
172
+ with lock:
173
+ spent[0] += record["usage"]["cost_usd"]
174
+ with summaries.open("a", encoding="utf-8") as fh:
175
+ fh.write(json.dumps(summary, sort_keys=True) + "\n")
176
+ with raw.open("a", encoding="utf-8") as fh:
177
+ fh.write(json.dumps({**record, "study_key": run.key}) + "\n")
178
+ elapsed = time.monotonic() - started
179
+ progress(
180
+ f"{summary['phase']} {harness:11s} {run.cell.repo:10s} {run.cell.slot:24s} "
181
+ f"{run.cell.condition:11s} t{run.trial} {summary['status']:7s} {summary['verdict']:13s} "
182
+ f"${summary['cost_usd']:.4f} total ${spent[0]:.2f} ({elapsed:.0f}s)"
183
+ )
184
+
185
+ try:
186
+ with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
187
+ for future in as_completed([pool.submit(one, r) for r in mine]):
188
+ future.result()
189
+ finally:
190
+ box.stop_batch(batch)
191
+ if stopped:
192
+ break
193
+ return {
194
+ "phase": phase,
195
+ "done": len(done_keys(summaries)),
196
+ "spent": spent[0],
197
+ "stopped": stopped[0] if stopped else None,
198
+ }