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/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Read, validate and write obey.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from obey_cli.resources import schema
13
+
14
+ CONFIG_NAME = "obey.yaml"
15
+ DEFAULT_ALLOW_HOSTS = [
16
+ "registry.npmjs.org",
17
+ "pypi.org",
18
+ "files.pythonhosted.org",
19
+ "proxy.golang.org",
20
+ "sum.golang.org",
21
+ "crates.io",
22
+ "static.crates.io",
23
+ "index.crates.io",
24
+ ]
25
+
26
+
27
+ class ConfigError(ValueError):
28
+ pass
29
+
30
+
31
+ def normalise_text(text: str) -> str:
32
+ return re.sub(r"\s+", " ", text).strip()
33
+
34
+
35
+ def rule_id(text: str) -> str:
36
+ """A stable slug of the rule text plus a short hash, e.g. `use-pnpm-for-all-package-operations-3f9a1c`."""
37
+ norm = normalise_text(text)
38
+ slug = re.sub(r"[^a-z0-9]+", "-", norm.lower()).strip("-")[:40].strip("-") or "rule"
39
+ return f"{slug}-{hashlib.sha1(norm.encode()).hexdigest()[:6]}" # noqa: S324 - an identifier, not security
40
+
41
+
42
+ def validate(data: Any) -> list[str]:
43
+ import jsonschema
44
+
45
+ validator = jsonschema.Draft202012Validator(schema("obey-config-v1.json"))
46
+ errors = []
47
+ for err in sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)):
48
+ where = "/".join(str(p) for p in err.absolute_path) or "(root)"
49
+ errors.append(f"{where}: {err.message}")
50
+ ids = [r.get("id") for r in (data.get("rules") or [])] if isinstance(data, dict) else []
51
+ dupes = sorted({i for i in ids if ids.count(i) > 1})
52
+ errors += [f"rules: duplicate id {d}" for d in dupes]
53
+ return errors
54
+
55
+
56
+ def load(path: Path) -> dict[str, Any]:
57
+ try:
58
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
59
+ except FileNotFoundError as exc:
60
+ raise ConfigError(f"{path} not found; run `obey extract` first") from exc
61
+ except yaml.YAMLError as exc:
62
+ raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
63
+ errors = validate(data)
64
+ if errors:
65
+ raise ConfigError(f"{path}: " + "; ".join(errors[:10]))
66
+ assert isinstance(data, dict)
67
+ return data
68
+
69
+
70
+ class _Dumper(yaml.SafeDumper):
71
+ pass
72
+
73
+
74
+ def _str_presenter(dumper: yaml.SafeDumper, value: str) -> yaml.ScalarNode:
75
+ style = "|" if "\n" in value else None
76
+ return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style)
77
+
78
+
79
+ _Dumper.add_representer(str, _str_presenter)
80
+
81
+
82
+ def dump(data: dict[str, Any]) -> str:
83
+ return yaml.dump(data, Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=120)
84
+
85
+
86
+ def save(path: Path, data: dict[str, Any]) -> None:
87
+ errors = validate(data)
88
+ if errors:
89
+ raise ConfigError("refusing to write an invalid obey.yaml: " + "; ".join(errors[:10]))
90
+ path.write_text(dump(data), encoding="utf-8")
obey_cli/discover.py ADDED
@@ -0,0 +1,232 @@
1
+ """Find the agent instruction files in a repository and work out what each one applies to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import Any
10
+
11
+ from obey_cli.parse_md import ParsedFile, parse
12
+
13
+ SKIP_DIRS = {
14
+ ".git",
15
+ "node_modules",
16
+ ".venv",
17
+ "venv",
18
+ "__pycache__",
19
+ ".tox",
20
+ ".mypy_cache",
21
+ ".ruff_cache",
22
+ ".pytest_cache",
23
+ "dist",
24
+ "build",
25
+ "target",
26
+ ".obey",
27
+ ".obey-work",
28
+ }
29
+ MAX_IMPORT_DEPTH = 4
30
+
31
+ # Which agents read which file kinds, as documented by each vendor (see docs/agents.md).
32
+ LOADED_BY: dict[str, tuple[str, ...]] = {
33
+ "agents_md": ("claude-code", "codex", "opencode", "cursor", "gemini", "copilot"),
34
+ "claude_md": ("claude-code", "opencode"),
35
+ "claude_import": ("claude-code",),
36
+ "claude_local_md": ("claude-code",),
37
+ "gemini_md": ("gemini",),
38
+ "cursor_mdc": ("cursor",),
39
+ "copilot_repo": ("copilot",),
40
+ "copilot_path": ("copilot",),
41
+ }
42
+
43
+ # How each agent resolves several AGENTS.md files on the way from the repository root to the edited file.
44
+ NESTED_SEMANTICS: dict[str, str] = {
45
+ "codex": "concatenates AGENTS.md from the repository root down to the working directory; later files win",
46
+ "opencode": "reads the nearest AGENTS.md walking up from the working directory",
47
+ "claude-code": "loads CLAUDE.md (or AGENTS.md) from the working directory upwards at start and nested files "
48
+ "when it reads files in their directories",
49
+ "cursor": "applies the nearest AGENTS.md to files below its directory",
50
+ "gemini": "loads context files from the working directory upwards and from subdirectories",
51
+ "copilot": "uses the nearest AGENTS.md in the directory tree",
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class InstructionFile:
57
+ path: str # repository-relative POSIX path
58
+ kind: str
59
+ scope_paths: list[str]
60
+ loaded_by: tuple[str, ...]
61
+ sha256: str
62
+ parsed: ParsedFile
63
+ imported_from: str | None = None
64
+ activation: str = "always" # always | globs | agent_requested | manual
65
+ notes: list[str] = field(default_factory=list)
66
+
67
+
68
+ def classify(rel: str) -> str | None:
69
+ p = PurePosixPath(rel)
70
+ name = p.name
71
+ parts = p.parts
72
+ if name == "AGENTS.md":
73
+ return "agents_md"
74
+ if name == "CLAUDE.md":
75
+ return "claude_md"
76
+ if name == "CLAUDE.local.md":
77
+ return "claude_local_md"
78
+ if name == "GEMINI.md":
79
+ return "gemini_md"
80
+ if name.endswith(".mdc") and len(parts) >= 3 and parts[-3:-1] == (".cursor", "rules"):
81
+ return "cursor_mdc"
82
+ if rel == ".github/copilot-instructions.md":
83
+ return "copilot_repo"
84
+ if name.endswith(".instructions.md") and parts[:2] == (".github", "instructions"):
85
+ return "copilot_path"
86
+ return None
87
+
88
+
89
+ def list_files(root: Path) -> list[str]:
90
+ """Repository-relative paths, from `git ls-files` when possible so ignored files never count."""
91
+ if (root / ".git").exists():
92
+ try:
93
+ out = subprocess.run(
94
+ ["git", "-C", str(root), "ls-files", "-co", "--exclude-standard", "-z"],
95
+ capture_output=True,
96
+ check=True,
97
+ timeout=60,
98
+ ).stdout.decode("utf-8", "replace")
99
+ return sorted(p for p in out.split("\0") if p and not _skipped(p))
100
+ except (OSError, subprocess.SubprocessError):
101
+ pass
102
+ found: list[str] = []
103
+ for path in root.rglob("*"):
104
+ rel = path.relative_to(root).as_posix()
105
+ if path.is_file() and not _skipped(rel):
106
+ found.append(rel)
107
+ return sorted(found)
108
+
109
+
110
+ def _skipped(rel: str) -> bool:
111
+ return any(part in SKIP_DIRS for part in PurePosixPath(rel).parts[:-1])
112
+
113
+
114
+ def _scope_for_dir(directory: str) -> list[str]:
115
+ return ["**"] if directory in ("", ".") else [f"{directory}/**"]
116
+
117
+
118
+ def _split_globs(value: Any) -> list[str]:
119
+ if value is None or value == "":
120
+ return []
121
+ if isinstance(value, list):
122
+ items = [str(v) for v in value]
123
+ else:
124
+ items = [g for g in (s.strip() for s in str(value).split(",")) if g]
125
+ return [g.strip().strip("\"'") for g in items if g.strip()]
126
+
127
+
128
+ def _anchor(globs: list[str], base: str) -> list[str]:
129
+ if base in ("", "."):
130
+ return globs
131
+ return [g if g.startswith(base + "/") else f"{base}/{g}" for g in globs]
132
+
133
+
134
+ def _sha256(data: bytes) -> str:
135
+ import hashlib
136
+
137
+ return hashlib.sha256(data).hexdigest()
138
+
139
+
140
+ def discover(root: Path, include_local: bool = False) -> list[InstructionFile]:
141
+ """Return every instruction file under `root`, with CLAUDE.md imports resolved."""
142
+ root = root.resolve()
143
+ result: list[InstructionFile] = []
144
+ seen: set[str] = set()
145
+ for rel in list_files(root):
146
+ kind = classify(rel)
147
+ if kind is None or rel in seen or (kind == "claude_local_md" and not include_local):
148
+ continue
149
+ data = (root / rel).read_bytes()
150
+ parsed = parse(data.decode("utf-8", "replace"))
151
+ directory = PurePosixPath(rel).parent.as_posix()
152
+ activation = "always"
153
+ notes: list[str] = []
154
+ if kind == "cursor_mdc":
155
+ base = PurePosixPath(rel).parent.parent.parent.as_posix()
156
+ globs = _split_globs(parsed.frontmatter.get("globs"))
157
+ always = str(parsed.frontmatter.get("alwaysApply", "")).lower() == "true"
158
+ if always:
159
+ scope = _scope_for_dir(base)
160
+ elif globs:
161
+ scope, activation = _anchor(globs, base), "globs"
162
+ elif parsed.frontmatter.get("description"):
163
+ scope, activation = _scope_for_dir(base), "agent_requested"
164
+ notes.append("applied when the agent decides the description is relevant")
165
+ else:
166
+ scope, activation = _scope_for_dir(base), "manual"
167
+ notes.append("applied only when referenced with @rule")
168
+ elif kind == "copilot_path":
169
+ globs = _split_globs(parsed.frontmatter.get("applyTo"))
170
+ scope = globs or ["**"]
171
+ activation = "globs" if globs else "always"
172
+ elif kind == "copilot_repo" or (directory == ".claude" and kind == "claude_md"):
173
+ scope = ["**"]
174
+ else:
175
+ scope = _scope_for_dir(directory)
176
+ item = InstructionFile(
177
+ path=rel,
178
+ kind=kind,
179
+ scope_paths=scope,
180
+ loaded_by=LOADED_BY[kind],
181
+ sha256=_sha256(data),
182
+ parsed=parsed,
183
+ activation=activation,
184
+ notes=notes,
185
+ )
186
+ result.append(item)
187
+ seen.add(rel)
188
+ if kind in ("claude_md", "claude_local_md", "agents_md"):
189
+ # Claude Code expands @imports in CLAUDE.md and, when it reads AGENTS.md, in AGENTS.md too.
190
+ result.extend(_resolve_imports(root, item, seen, depth=1))
191
+ return result
192
+
193
+
194
+ def _resolve_imports(root: Path, parent: InstructionFile, seen: set[str], depth: int) -> list[InstructionFile]:
195
+ if depth > MAX_IMPORT_DEPTH:
196
+ return []
197
+ out: list[InstructionFile] = []
198
+ base = PurePosixPath(parent.path).parent
199
+ for target, _line in parent.parsed.imports:
200
+ if target.startswith("~") or target.startswith("/"):
201
+ parent.notes.append(f"import @{target} points outside the repository; not followed")
202
+ continue
203
+ rel = (base / target).as_posix()
204
+ resolved = (root / rel).resolve()
205
+ try:
206
+ rel = resolved.relative_to(root).as_posix()
207
+ except ValueError:
208
+ parent.notes.append(f"import @{target} points outside the repository; not followed")
209
+ continue
210
+ if rel in seen:
211
+ continue
212
+ if not resolved.is_file():
213
+ parent.notes.append(f"import @{target} not found")
214
+ continue
215
+ data = resolved.read_bytes()
216
+ child = InstructionFile(
217
+ path=rel,
218
+ kind="claude_import",
219
+ scope_paths=list(parent.scope_paths),
220
+ loaded_by=LOADED_BY["claude_import"],
221
+ sha256=_sha256(data),
222
+ parsed=parse(data.decode("utf-8", "replace")),
223
+ imported_from=parent.path,
224
+ )
225
+ seen.add(rel)
226
+ out.append(child)
227
+ out.extend(_resolve_imports(root, child, seen, depth + 1))
228
+ return out
229
+
230
+
231
+ def scope_matches(scope_paths: list[str], path: str) -> bool:
232
+ return any(fnmatch.fnmatchcase(path, g) or (g.endswith("/**") and path.startswith(g[:-2])) for g in scope_paths)
@@ -0,0 +1,114 @@
1
+ """Compile rules into hooks for each agent plus an agent-agnostic pre-commit check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Iterable, Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from obey_cli import __version__
12
+ from obey_cli.enforce.merge import Plan
13
+ from obey_cli.enforce.targets import EDIT_TYPES, SHELL_TYPES, STOP_TYPES, TARGETS
14
+
15
+ HOOK_DIR = ".obey/hooks"
16
+ TEMPLATES = Path(__file__).parent / "templates"
17
+ ENGINE = Path(__file__).parent / "engine" / "obey_engine.py"
18
+ MECHANICAL = SHELL_TYPES | EDIT_TYPES | STOP_TYPES
19
+ GITIGNORE = "backup/\nledger.jsonl\nruns/\nreport.*\n"
20
+
21
+
22
+ def compiled(rules: Sequence[dict[str, Any]]) -> dict[str, Any]:
23
+ body = [{"id": r["id"], "text": r["text"], "source": r["source"], "checker": r["checker"]} for r in rules]
24
+ digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
25
+ return {"obey_version": __version__, "rules_sha256": digest, "rules": body}
26
+
27
+
28
+ def select(
29
+ config: dict[str, Any],
30
+ *,
31
+ report: dict[str, Any] | None = None,
32
+ ids: Iterable[str] | None = None,
33
+ threshold: float = 1.0,
34
+ ) -> list[dict[str, Any]]:
35
+ """Active rules with a mechanical checker; with a report, only those broken in some exercised run."""
36
+ active = [
37
+ r
38
+ for r in config.get("rules", [])
39
+ if r.get("status") == "active" and r.get("checker") and r["checker"]["type"] in MECHANICAL
40
+ ]
41
+ if ids is not None:
42
+ wanted = set(ids)
43
+ return [r for r in active if r["id"] in wanted]
44
+ if report is None:
45
+ return active
46
+ broken = {
47
+ s["rule_id"]
48
+ for s in report.get("scores", [])
49
+ if s.get("arm") == "rule" and s.get("compliance") is not None and s["compliance"] < threshold
50
+ }
51
+ return [r for r in active if r["id"] in broken]
52
+
53
+
54
+ def advisory(rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> list[dict[str, str]]:
55
+ """Rules a target cannot enforce mechanically (listed as advisory only)."""
56
+ out = []
57
+ for r in rules:
58
+ kind = r["checker"]["type"]
59
+ for name in targets:
60
+ t = TARGETS[name]
61
+ stop_only = kind in STOP_TYPES and kind not in (SHELL_TYPES | EDIT_TYPES)
62
+ if (stop_only and not t.stop and name != "precommit") or (
63
+ name == "precommit" and kind in ("command_required", "final_message", "no_push", "no_commit")
64
+ ):
65
+ out.append({"rule_id": r["id"], "target": name, "reason": f"{t.display} cannot block {kind}"})
66
+ return out
67
+
68
+
69
+ def plan_enforce(root: Path, rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> Plan:
70
+ plan = Plan(root)
71
+ plan.put(f"{HOOK_DIR}/obey_hook.py", (TEMPLATES / "obey_hook.py").read_text(encoding="utf-8"))
72
+ plan.put(f"{HOOK_DIR}/obey_engine.py", ENGINE.read_text(encoding="utf-8"))
73
+ plan.put(f"{HOOK_DIR}/rules.json", json.dumps(compiled(rules), indent=2) + "\n")
74
+ plan.put(".obey/.gitignore", GITIGNORE)
75
+ if "precommit" in targets:
76
+ plan.put(".obey/pre-commit", (TEMPLATES / "pre_commit.py").read_text(encoding="utf-8"))
77
+ elif (root / ".obey/pre-commit").exists():
78
+ plan.put(".obey/pre-commit", None)
79
+ for name in targets:
80
+ TARGETS[name].write(plan, rules)
81
+ for name, target in TARGETS.items():
82
+ if name not in targets:
83
+ target.remove(plan)
84
+ return plan
85
+
86
+
87
+ def plan_remove(root: Path) -> Plan:
88
+ plan = Plan(root)
89
+ for target in TARGETS.values():
90
+ target.remove(plan)
91
+ for rel in (f"{HOOK_DIR}/obey_hook.py", f"{HOOK_DIR}/obey_engine.py", f"{HOOK_DIR}/rules.json", ".obey/pre-commit"):
92
+ if (root / rel).exists():
93
+ plan.put(rel, None)
94
+ return plan
95
+
96
+
97
+ def in_sync(root: Path, rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> list[str]:
98
+ """Files whose content differs from what `obey enforce` would write now (empty when in sync)."""
99
+ plan = plan_enforce(root, rules, targets)
100
+ return sorted(plan.changes)
101
+
102
+
103
+ def installed_targets(root: Path) -> list[str]:
104
+ found = []
105
+ for name, target in TARGETS.items():
106
+ for rel in target.files:
107
+ path = root / rel
108
+ if path.exists() and ".obey/hooks/obey_hook.py" in path.read_text(encoding="utf-8", errors="replace"):
109
+ found.append(name)
110
+ break
111
+ if name == "precommit" and path.exists():
112
+ found.append(name)
113
+ break
114
+ return found
File without changes