agentltl-coding 0.2.4__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 (40) hide show
  1. agentltl_coding/__init__.py +21 -0
  2. agentltl_coding/cli.py +482 -0
  3. agentltl_coding/edit.py +67 -0
  4. agentltl_coding/guard.py +461 -0
  5. agentltl_coding/harness.py +110 -0
  6. agentltl_coding/library/ask-before-creating-credentials.yaml +9 -0
  7. agentltl_coding/library/ask-before-discarding-work.yaml +12 -0
  8. agentltl_coding/library/ask-before-infra-changes.yaml +9 -0
  9. agentltl_coding/library/ask-before-installing.yaml +12 -0
  10. agentltl_coding/library/ask-before-reading-secret-stores.yaml +35 -0
  11. agentltl_coding/library/ask-before-recursive-delete.yaml +7 -0
  12. agentltl_coding/library/devops-secrets.yaml +10 -0
  13. agentltl_coding/library/no-claude-coauthor.yaml +11 -0
  14. agentltl_coding/library/no-codex-coauthor.yaml +11 -0
  15. agentltl_coding/library/no-copilot-coauthor.yaml +11 -0
  16. agentltl_coding/library/no-env-dumps.yaml +18 -0
  17. agentltl_coding/library/no-force-push.yaml +7 -0
  18. agentltl_coding/library/no-leaky-debug.yaml +13 -0
  19. agentltl_coding/library/no-push-to-main.yaml +7 -0
  20. agentltl_coding/library/no-secrets-in-commands.yaml +21 -0
  21. agentltl_coding/library/no-skipping-secret-scans.yaml +9 -0
  22. agentltl_coding/library/no-vibe-coauthor.yaml +11 -0
  23. agentltl_coding/library/protect-env-files.yaml +9 -0
  24. agentltl_coding/library/protect-secret-files.yaml +53 -0
  25. agentltl_coding/library/read-before-overwrite.yaml +11 -0
  26. agentltl_coding/library/subagents-on-sonnet.yaml +12 -0
  27. agentltl_coding/library/tests-before-finishing.yaml +13 -0
  28. agentltl_coding/library/tests-before-push.yaml +14 -0
  29. agentltl_coding/memory.py +390 -0
  30. agentltl_coding/pattern.py +291 -0
  31. agentltl_coding/rules.py +920 -0
  32. agentltl_coding/scan.py +54 -0
  33. agentltl_coding/session.py +149 -0
  34. agentltl_coding/store.py +123 -0
  35. agentltl_coding-0.2.4.dist-info/METADATA +129 -0
  36. agentltl_coding-0.2.4.dist-info/RECORD +40 -0
  37. agentltl_coding-0.2.4.dist-info/WHEEL +5 -0
  38. agentltl_coding-0.2.4.dist-info/entry_points.txt +2 -0
  39. agentltl_coding-0.2.4.dist-info/licenses/LICENSE +21 -0
  40. agentltl_coding-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,920 @@
1
+ """
2
+ agentltl_coding/rules.py – AGENTLTL.yaml → AgentLTL constraints.
3
+
4
+ settings:
5
+ mode: block # default escalation for every rule
6
+ retries: 3 # attempts allowed by mode: retry before asking you
7
+ retry_counting: cumulative # or consecutive (reset by an allowed call), hybrid
8
+ report: 3 # violations of the same mode one refusal may list
9
+ unparseable: {interactive: ask, auto: note}
10
+ announce: true # list the rules to the agent at the start of a session
11
+ scope: session # default memory for rules: session | project
12
+ scan_output: true # warn when a call's output contains a credential
13
+ rules:
14
+ - id: tests-before-push
15
+ before: {first: pytest, then: git_push, since: [Edit, Write]}
16
+ why: CI is slow; run the tests locally first.
17
+ fix: Run pytest, then push.
18
+ mode: warn
19
+ from: {file: CLAUDE.md, line: 12, id: 3f2a9c01d4} # optional: the memory it came from
20
+ use: [no-force-push, {tests-before-push: {mode: warn}}] # packaged rules, see library/
21
+ disable: [memory-first] # switch off rules by id (from use:, the user file, or built in)
22
+ tools: # cli-to-tools specs for your own commands
23
+ deploy: {options: [{flags: [--prod], type: bool}]}
24
+
25
+ Rule kinds (exactly one per rule; targets are described in :mod:`agentltl_coding.pattern`),
26
+ each compiled to an AgentLTL formula over the calls up to the one being checked:
27
+
28
+ never: T T is never called G ¬matches(T)
29
+ before: [A, B] B only once A has been called; G(matches(B) → Y O matches(A))
30
+ dict form adds ``since: S`` ... Y(¬matches(S) S matches(A))
31
+ (an A must come after the last S)
32
+ require: T when one of T's tools is called, G(T's tools → matches(T))
33
+ its arguments match T
34
+ at_most: {call: T, times: n} G(matches(T) → < n earlier)
35
+ finally: T before finishing, T has run F matches(T)
36
+ finally: {call: T, since: S} ... after the last S G(matches(S) → F matches(T))
37
+ ltl: '...' raw AgentLTL formula (``agentltl.parse`` syntax)
38
+ formula: {type, args} structured AgentLTL formula
39
+
40
+ ``finally`` rules never refuse a call: they are checked when the agent is about to finish
41
+ (:meth:`agentltl_coding.Guard.finish`), which sends it back to do what is missing, at most
42
+ ``finish_retries`` times per turn.
43
+
44
+ Every kind but ``finally``/``ltl``/``formula`` is past-only under ``G``, so AgentLTL judges it on the
45
+ call being checked alone: a rule broken earlier (by an override) never blocks unrelated
46
+ calls. A call whose files are only known at run time may match: that counts against it for
47
+ ``never``/``before``'s ``then``/``at_most``, and does not satisfy ``require`` or ``before``'s
48
+ ``first``.
49
+
50
+ ``never``, ``require`` and ``at_most`` also take ``with:`` / ``where:`` at rule level.
51
+
52
+ ``scope`` says which memory a rule reads: ``session`` (the calls made in this session;
53
+ starts empty with each new session, kept across compaction and resume) or ``project``
54
+ (every call made in this project, across sessions; never reset automatically).
55
+
56
+ Rules from the user's AGENTLTL.yaml (see :class:`agentltl_coding.Harness`) apply everywhere;
57
+ the project file adds to them and replaces a user rule with the same id. A harness may add
58
+ built-in rules, each switched off by a setting of its own (``memory_first: false``).
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import os
64
+ from dataclasses import dataclass, field
65
+ from typing import Any, Callable, Dict, List, Optional, Tuple
66
+
67
+ import yaml
68
+
69
+ from .harness import current as _harness
70
+ from .pattern import Paths, RuleError, parse_target, tool_matches
71
+
72
+ _MAYBE = (" Its file arguments are only known at run time (xargs, find -exec, or a $VARIABLE),"
73
+ " so the guard cannot rule it out; name the files explicitly.")
74
+
75
+ FILE_NAME = "AGENTLTL.yaml"
76
+
77
+ # escalation mode -> AgentLTL ConstraintSeverity name, one to one
78
+ MODES: Dict[str, str] = {
79
+ "block": "PERSISTENT_BLOCK", # denied every time; the agent cannot override
80
+ "warn": "BLOCK_AND_WARN", # denied once; the agent may insist by repeating the exact call
81
+ "retry": "SOFT_BLOCK", # denied up to `retries` times, then you are asked
82
+ "ask": "ASK", # you are asked every time
83
+ "stop": "HARD_STOP", # denied, and the agent stops until you reply
84
+ "log": "TOLERATE", # allowed; the agent is told it broke the rule
85
+ }
86
+ _MODE_HELP: Dict[str, str] = {
87
+ "block": "denied every time; only you can let it through (edit the rule, or run it yourself)",
88
+ "warn": "denied once; {agent} may override by repeating the exact same call",
89
+ "retry": "denied, and after the allowed retries you are asked to approve",
90
+ "ask": "you are asked to approve every time",
91
+ "stop": "denied and {agent} stops working until you reply",
92
+ "log": "allowed, but {agent} is told the rule was broken",
93
+ }
94
+
95
+
96
+ def mode_help(mode: str) -> str:
97
+ return _MODE_HELP[mode].format(agent=_harness().agent)
98
+
99
+
100
+ # Strongest first. When one call breaks several rules, the strongest mode decides (AgentLTL's
101
+ # SEVERITY_STRENGTH); that also keeps a `warn` override (repeat the exact call) from slipping
102
+ # past a stronger rule the same call breaks.
103
+ STRENGTH = ("stop", "block", "ask", "retry", "warn", "log")
104
+ UNPARSEABLE = ("ask", "note", "allow", "deny")
105
+ SCOPES = ("session", "project")
106
+ KINDS = ("never", "before", "require", "at_most", "finally", "ltl", "formula")
107
+ _RULE_KEYS = {"id", "why", "fix", "mode", "scope", "with", "where", "from", *KINDS}
108
+ _FROM_KEYS = {"file", "line", "id", "text"}
109
+
110
+
111
+ @dataclass
112
+ class Settings:
113
+ mode: str = "block"
114
+ retries: int = 3
115
+ unparseable_interactive: str = "ask"
116
+ unparseable_auto: str = "note"
117
+ announce: bool = True
118
+ scope: str = "session"
119
+ scan_output: bool = True
120
+ retry_counting: str = "cumulative" # cumulative | consecutive | hybrid
121
+ report: int = 3 # violations one refusal may list (nudge_max)
122
+ finish_retries: int = 2 # times per turn `finally` rules send the agent back
123
+ builtins: Dict[str, bool] = field(default_factory=dict) # harness built-in rule switches
124
+
125
+ def builtin(self, name: str) -> bool:
126
+ return self.builtins.get(name, True)
127
+
128
+ @property
129
+ def memory_first(self) -> bool: # the harnesses' memory built-in, by its setting name
130
+ return self.builtin("memory_first")
131
+
132
+
133
+ @dataclass
134
+ class Rule:
135
+ id: str
136
+ kind: str
137
+ why: str
138
+ fix: str
139
+ mode: str
140
+ formula: Any
141
+ tools: Tuple[str, ...] = ()
142
+ source: str = ""
143
+ targets: Tuple[Any, ...] = ()
144
+ scope: str = "session"
145
+ origins: Tuple[Dict[str, Any], ...] = () # `from:` the memory statements it enforces
146
+ label: str = ""
147
+ # Why a call breaks the rule, in plain words, given the calls up to and including it
148
+ # (None for ltl/formula rules, whose explanation is AgentLTL's own).
149
+ explain: Optional[Callable[[List[Any]], Optional[str]]] = None
150
+
151
+ @property
152
+ def summary(self) -> str:
153
+ """One line saying what the rule checks."""
154
+ return self.label or str(self.formula)
155
+
156
+ def constraint(self) -> Any:
157
+ from agentltl import Constraint
158
+ return Constraint(self.id, self.formula, description=self.why, repair=self.fix,
159
+ applies_to_final_answer=self.kind == "finally")
160
+
161
+
162
+ @dataclass
163
+ class RuleSet:
164
+ rules: List[Rule] = field(default_factory=list)
165
+ settings: Settings = field(default_factory=Settings)
166
+ tool_specs: Dict[str, Any] = field(default_factory=dict)
167
+ files: List[str] = field(default_factory=list)
168
+ explicit_settings: bool = False
169
+ disabled: List[str] = field(default_factory=list) # ids switched off by `disable:`
170
+ used: List[str] = field(default_factory=list) # library packs named by `use:`
171
+
172
+ def get(self, rule_id: str) -> Optional[Rule]:
173
+ return next((r for r in self.rules if r.id == rule_id), None)
174
+
175
+ def scoped(self, scope: Optional[str]) -> List[Rule]:
176
+ return [r for r in self.rules if scope is None or r.scope == scope]
177
+
178
+ def constraints(self, scope: Optional[str] = None) -> List[Any]:
179
+ """Constraints of one scope (all when None), strongest mode first (see STRENGTH)."""
180
+ ordered = sorted(self.scoped(scope), key=lambda r: STRENGTH.index(r.mode))
181
+ return [r.constraint() for r in ordered]
182
+
183
+ def severities(self, scope: Optional[str] = None) -> Dict[str, Any]:
184
+ from agentltl import ConstraintSeverity
185
+ return {r.id: ConstraintSeverity[MODES[r.mode]] for r in self.scoped(scope)}
186
+
187
+
188
+ class RuleFileError(Exception):
189
+ """One or more problems in a rule file; ``problems`` lists them with locations."""
190
+
191
+ def __init__(self, problems: List[str]) -> None:
192
+ super().__init__("\n".join(problems))
193
+ self.problems = problems
194
+
195
+
196
+ # ── locating and loading ──────────────────────────────────────────────────────
197
+
198
+ def find_rule_file(start: str, stop: Optional[str] = None) -> Optional[str]:
199
+ """The nearest AGENTLTL.yaml from *start* upwards, not above *stop* or the git root."""
200
+ here = os.path.abspath(start or os.getcwd())
201
+ stop = os.path.abspath(stop) if stop else None
202
+ while True:
203
+ candidate = os.path.join(here, FILE_NAME)
204
+ if os.path.isfile(candidate):
205
+ return candidate
206
+ if here == stop or os.path.exists(os.path.join(here, ".git")):
207
+ return None
208
+ parent = os.path.dirname(here)
209
+ if parent == here:
210
+ return None
211
+ here = parent
212
+
213
+
214
+ def user_rule_file() -> Optional[str]:
215
+ path = _harness().user_file()
216
+ return path if os.path.isfile(path) else None
217
+
218
+
219
+ def rule_files(cwd: str, project_dir: Optional[str] = None) -> List[str]:
220
+ """User-level then project-level rule files that apply in *cwd*."""
221
+ files = [f for f in (user_rule_file(), find_rule_file(cwd, project_dir)) if f]
222
+ return list(dict.fromkeys(files))
223
+
224
+
225
+ def load(files: List[str], paths: Optional[Paths] = None) -> RuleSet:
226
+ """Compile the rule files, later files overriding earlier ones.
227
+
228
+ Raises:
229
+ RuleFileError: With every problem found, each prefixed by ``file:line``.
230
+ """
231
+ merged = RuleSet(files=list(files))
232
+ problems: List[str] = []
233
+ for path in files:
234
+ try:
235
+ with open(path, encoding="utf-8") as fh:
236
+ text = fh.read()
237
+ part = loads(text, paths, source=path)
238
+ except RuleFileError as exc:
239
+ problems += exc.problems
240
+ continue
241
+ except OSError as exc:
242
+ problems.append(f"{path}: {exc}")
243
+ continue
244
+ by_id = {r.id: i for i, r in enumerate(merged.rules)}
245
+ for rule in part.rules:
246
+ if rule.id in by_id:
247
+ merged.rules[by_id[rule.id]] = rule
248
+ else:
249
+ merged.rules.append(rule)
250
+ merged.tool_specs.update(part.tool_specs)
251
+ merged.rules = [r for r in merged.rules if r.id not in part.disabled]
252
+ merged.disabled += part.disabled
253
+ merged.used += part.used
254
+ if part.explicit_settings:
255
+ merged.settings = part.settings
256
+ if problems:
257
+ raise RuleFileError(problems)
258
+ for setting, raw in _harness().builtins.items():
259
+ if (merged.settings.builtin(setting) and not merged.get(raw["id"])
260
+ and raw["id"] not in merged.disabled):
261
+ builtin = compile_rule(raw, merged.settings, paths or Paths(), "built-in")
262
+ builtin.source = "built-in"
263
+ merged.rules.append(builtin)
264
+ return merged
265
+
266
+
267
+ def loads(text: str, paths: Optional[Paths] = None, source: str = "<rules>") -> RuleSet:
268
+ """Compile one rule file's text."""
269
+ try:
270
+ data = yaml.safe_load(text) or {}
271
+ lines = _rule_lines(text)
272
+ except yaml.YAMLError as exc:
273
+ mark = getattr(exc, "problem_mark", None)
274
+ where = f"{source}:{mark.line + 1}" if mark else source
275
+ raise RuleFileError([f"{where}: invalid YAML: {getattr(exc, 'problem', exc)}"]) from None
276
+ if not isinstance(data, dict):
277
+ raise RuleFileError([f"{source}: expected a mapping with 'rules' (and optionally "
278
+ "'settings', 'tools')"])
279
+ problems: List[str] = []
280
+ unknown = set(data) - {"version", "settings", "rules", "tools", "use", "disable"}
281
+ if unknown:
282
+ problems.append(f"{source}: unknown top-level key(s) {sorted(unknown)}")
283
+ settings = Settings()
284
+ try:
285
+ settings = _settings(data.get("settings"))
286
+ except RuleError as exc:
287
+ problems.append(f"{source}: settings: {exc}")
288
+
289
+ tools = data.get("tools") or {}
290
+ if not isinstance(tools, dict):
291
+ problems.append(f"{source}: 'tools' must map command names to cli-to-tools specs")
292
+ tools = {}
293
+ else:
294
+ problems += _check_tools(tools, source)
295
+
296
+ raw_rules = data.get("rules") or []
297
+ if not isinstance(raw_rules, list):
298
+ raise RuleFileError(problems + [f"{source}: 'rules' must be a list"])
299
+ rules: List[Rule] = []
300
+ seen: Dict[str, str] = {}
301
+ paths = paths or Paths()
302
+ try:
303
+ packed, packed_tools = _use(data.get("use"), settings, paths)
304
+ except RuleError as exc:
305
+ problems.append(f"{source}: use: {exc}")
306
+ packed, packed_tools = [], {}
307
+ tools = {**packed_tools, **tools}
308
+ disabled = data.get("disable") or []
309
+ if not isinstance(disabled, list) or not all(isinstance(d, str) for d in disabled):
310
+ problems.append(f"{source}: disable: expected a list of rule ids")
311
+ disabled = []
312
+ for i, raw in enumerate(raw_rules):
313
+ where = f"{source}:{lines[i]}" if i < len(lines) else f"{source}: rules[{i}]"
314
+ try:
315
+ rule = compile_rule(raw, settings, paths, where)
316
+ except RuleError as exc:
317
+ problems.append(f"{where}: {exc}")
318
+ continue
319
+ if rule.id in seen:
320
+ problems.append(f"{where}: duplicate id '{rule.id}' (first at {seen[rule.id]})")
321
+ continue
322
+ seen[rule.id] = where
323
+ rule.source = source
324
+ rules.append(rule)
325
+ if problems:
326
+ raise RuleFileError(problems)
327
+ own = {r.id for r in rules} # a rule of the file replaces a library rule by id
328
+ rules = [r for r in packed if r.id not in own] + rules
329
+ rules = [r for r in rules if r.id not in disabled]
330
+ return RuleSet(rules, settings, tools, [source], "settings" in data, list(disabled),
331
+ [_use_name(u) for u in data.get("use") or []])
332
+
333
+
334
+ def _use_name(item: Any) -> str:
335
+ return item if isinstance(item, str) else next(iter(item))
336
+
337
+
338
+ # ── the rule library ──────────────────────────────────────────────────────────
339
+
340
+ LIBRARY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "library")
341
+
342
+
343
+ def library() -> Dict[str, Dict[str, Any]]:
344
+ """The packaged rules, by name: each has ``summary``, ``tags``, ``rules``, ``tools``, and
345
+ for a bundle, ``include`` (other entries it switches on). ``harnesses`` lists the agents an
346
+ entry is for (absent: every one); elsewhere ``use:`` of it switches nothing on."""
347
+ out: Dict[str, Dict[str, Any]] = {}
348
+ if os.path.isdir(LIBRARY_DIR):
349
+ for name in sorted(os.listdir(LIBRARY_DIR)):
350
+ if name.endswith(".yaml"):
351
+ with open(os.path.join(LIBRARY_DIR, name), encoding="utf-8") as fh:
352
+ pack = yaml.safe_load(fh) or {}
353
+ pack["path"] = os.path.join(LIBRARY_DIR, name)
354
+ out[name[:-5]] = pack
355
+ return out
356
+
357
+
358
+ def _use(raw: Any, settings: Settings, paths: Paths) -> Tuple[List[Rule], Dict[str, Any]]:
359
+ """Compile ``use: [name, {name: {mode: warn}}]`` into the library's rules."""
360
+ if raw is None:
361
+ return [], {}
362
+ if not isinstance(raw, list):
363
+ raise RuleError("expected a list of library rule names (see `agentltl library`)")
364
+ packs = library()
365
+ rules: List[Rule] = []
366
+ tools: Dict[str, Any] = {}
367
+ for item in raw:
368
+ name, extra = (item, {}) if isinstance(item, str) else (
369
+ next(iter(item.items())) if isinstance(item, dict) and len(item) == 1 else (None, None))
370
+ if name is None or not isinstance(extra, dict) or set(extra) - {"mode", "scope", "from"}:
371
+ raise RuleError(f"{item!r}: expected a name, or "
372
+ "{name: {mode: ..., scope: ..., from: ...}}")
373
+ if name not in packs:
374
+ import difflib
375
+ close = difflib.get_close_matches(name, packs, n=1)
376
+ hint = f" (did you mean '{close[0]}'?)" if close else ""
377
+ raise RuleError(f"no library rule '{name}'{hint}; `agentltl library` lists them")
378
+ for entry in _expand(name, packs):
379
+ pack = packs[entry]
380
+ if not _harness().matches(pack.get("harnesses")):
381
+ continue # another agent's rule (a project file both share)
382
+ for i, rule in enumerate(pack.get("rules") or []):
383
+ if any(r.id == rule.get("id") for r in rules):
384
+ continue # already switched on by another entry or bundle
385
+ compiled = compile_rule({**rule, **extra}, settings, paths,
386
+ f"library:{entry}[{i}]")
387
+ compiled.source = f"library:{entry}"
388
+ rules.append(compiled)
389
+ tools.update(pack.get("tools") or {})
390
+ return rules, tools
391
+
392
+
393
+ def _expand(name: str, packs: Dict[str, Dict[str, Any]], seen: Tuple[str, ...] = ()) -> List[str]:
394
+ """*name* and, for a bundle, the entries its ``include:`` lists (recursively)."""
395
+ if name in seen:
396
+ raise RuleError(f"library entry '{name}' includes itself")
397
+ if name not in packs:
398
+ raise RuleError(f"library entry '{seen[-1]}' includes unknown '{name}'")
399
+ out = [name]
400
+ for sub in packs[name].get("include") or []:
401
+ out += [n for n in _expand(sub, packs, seen + (name,)) if n not in out]
402
+ return out
403
+
404
+
405
+ def _rule_lines(text: str) -> List[int]:
406
+ node = yaml.compose(text)
407
+ if not isinstance(node, yaml.MappingNode):
408
+ return []
409
+ for key, value in node.value:
410
+ if key.value == "rules" and isinstance(value, yaml.SequenceNode):
411
+ return [item.start_mark.line + 1 for item in value.value]
412
+ return []
413
+
414
+
415
+ def _settings(raw: Any) -> Settings:
416
+ if raw is None:
417
+ return Settings()
418
+ if not isinstance(raw, dict):
419
+ raise RuleError("expected a mapping")
420
+ switches = set(_harness().builtins)
421
+ unknown = set(raw) - {"mode", "retries", "unparseable", "announce", "scope", "scan_output",
422
+ "retry_counting", "report", "finish_retries"} - switches
423
+ if unknown:
424
+ raise RuleError(f"unknown key(s) {sorted(unknown)}")
425
+ s = Settings()
426
+ for key in switches & set(raw):
427
+ if not isinstance(raw[key], bool):
428
+ raise RuleError(f"{key} must be true or false")
429
+ s.builtins[key] = raw[key]
430
+ if "retry_counting" in raw:
431
+ if raw["retry_counting"] not in ("cumulative", "consecutive", "hybrid"):
432
+ raise RuleError("retry_counting must be cumulative, consecutive or hybrid")
433
+ s.retry_counting = raw["retry_counting"]
434
+ if "report" in raw:
435
+ if not isinstance(raw["report"], int) or raw["report"] < 1:
436
+ raise RuleError("report must be a positive integer")
437
+ s.report = raw["report"]
438
+ if "finish_retries" in raw:
439
+ if not isinstance(raw["finish_retries"], int) or raw["finish_retries"] < 0:
440
+ raise RuleError("finish_retries must be a non-negative integer")
441
+ s.finish_retries = raw["finish_retries"]
442
+ if "mode" in raw:
443
+ s.mode = _mode(raw["mode"])
444
+ if "retries" in raw:
445
+ if not isinstance(raw["retries"], int) or raw["retries"] < 1:
446
+ raise RuleError("retries must be a positive integer")
447
+ s.retries = raw["retries"]
448
+ if "scope" in raw:
449
+ s.scope = _scope(raw["scope"])
450
+ if "announce" in raw:
451
+ if not isinstance(raw["announce"], bool):
452
+ raise RuleError("announce must be true or false")
453
+ s.announce = raw["announce"]
454
+ if "scan_output" in raw:
455
+ if not isinstance(raw["scan_output"], bool):
456
+ raise RuleError("scan_output must be true or false")
457
+ s.scan_output = raw["scan_output"]
458
+ unp = raw.get("unparseable")
459
+ if isinstance(unp, str):
460
+ unp = {"interactive": unp, "auto": unp}
461
+ if unp is not None:
462
+ if not isinstance(unp, dict) or set(unp) - {"interactive", "auto"}:
463
+ raise RuleError("unparseable must be one of ask/note/allow/deny, or a mapping with "
464
+ "'interactive' and/or 'auto'")
465
+ for key, value in unp.items():
466
+ if value not in UNPARSEABLE:
467
+ raise RuleError(f"unparseable.{key} must be one of {', '.join(UNPARSEABLE)}")
468
+ s.unparseable_interactive = unp.get("interactive", s.unparseable_interactive)
469
+ s.unparseable_auto = unp.get("auto", s.unparseable_auto)
470
+ return s
471
+
472
+
473
+ def _scope(value: Any) -> str:
474
+ if value not in SCOPES:
475
+ raise RuleError(f"scope must be one of {', '.join(SCOPES)}, got {value!r}")
476
+ return value
477
+
478
+
479
+ def _mode(value: Any) -> str:
480
+ if value not in MODES:
481
+ raise RuleError(f"mode must be one of {', '.join(MODES)}, got {value!r}")
482
+ return value
483
+
484
+
485
+ def _check_tools(tools: Dict[str, Any], source: str) -> List[str]:
486
+ try:
487
+ from cli_to_tools import SpecRegistry
488
+ except ImportError:
489
+ return []
490
+ try:
491
+ SpecRegistry(packs=[]).load_dict(tools)
492
+ except Exception as exc: # the spec loader raises whatever argparse or YAML shapes give
493
+ return [f"{source}: tools: {exc}"]
494
+ return []
495
+
496
+
497
+ # ── compiling one rule ────────────────────────────────────────────────────────
498
+
499
+ def compile_rule(raw: Any, settings: Settings, paths: Paths, where: str = "rule") -> Rule:
500
+ if not isinstance(raw, dict):
501
+ raise RuleError("a rule must be a mapping with an id and one rule kind")
502
+ unknown = set(raw) - _RULE_KEYS
503
+ if unknown:
504
+ raise RuleError(f"unknown key(s) {sorted(unknown)}; rule kinds are {', '.join(KINDS)}")
505
+ rule_id = raw.get("id")
506
+ if not isinstance(rule_id, str) or not rule_id.strip():
507
+ raise RuleError("missing 'id'")
508
+ kinds = [k for k in KINDS if k in raw]
509
+ if len(kinds) != 1:
510
+ raise RuleError(f"rule '{rule_id}' needs exactly one of {', '.join(KINDS)}"
511
+ + (f", has {', '.join(kinds)}" if kinds else ""))
512
+ kind = kinds[0]
513
+ if ("with" in raw or "where" in raw) and kind not in ("never", "require", "at_most"):
514
+ raise RuleError(f"rule '{rule_id}': 'with'/'where' at rule level only apply to "
515
+ "never, require and at_most; put them on a target instead")
516
+ mode = _mode(raw.get("mode", settings.mode))
517
+ scope = _scope(raw.get("scope", settings.scope))
518
+ why = str(raw.get("why") or "").strip()
519
+ fix = str(raw.get("fix") or "").strip()
520
+ origins = _origins(raw.get("from"), rule_id)
521
+ formula, label, explain, tools, *targets = _BUILDERS[kind](raw, paths, f"{rule_id}.{kind}")
522
+ if kind in ("ltl", "formula"):
523
+ _require_runtime_safe(formula, rule_id)
524
+ return Rule(rule_id, kind, why, fix, mode, formula, tools, where, tuple(targets), scope,
525
+ origins, label, explain)
526
+
527
+
528
+ def _origins(raw: Any, rule_id: str) -> Tuple[Dict[str, Any], ...]:
529
+ """``from:``: the memory statement(s) a rule was written from (see ``agentltl memory``).
530
+ A statement id alone, a mapping with file/line/id/text, or a list of these."""
531
+ if raw is None:
532
+ return ()
533
+ items = raw if isinstance(raw, list) else [raw]
534
+ out = []
535
+ for item in items:
536
+ if isinstance(item, str):
537
+ item = {"id": item}
538
+ if not isinstance(item, dict) or not item or set(item) - _FROM_KEYS:
539
+ raise RuleError(f"rule '{rule_id}': 'from' takes a statement id, or a mapping with "
540
+ f"{', '.join(sorted(_FROM_KEYS))}")
541
+ out.append(item)
542
+ return tuple(out)
543
+
544
+
545
+ class _Pattern:
546
+ """A rule target as an AgentLTL call pattern: ``match`` answers True, False, or None
547
+ for "maybe" (files only known at run time), with paths resolved from *paths*."""
548
+
549
+ def __init__(self, target: Any, paths: Paths) -> None:
550
+ self.target, self.paths = target, paths
551
+ self.tools = target.tools
552
+
553
+ def match(self, name: str, args: Dict[str, Any], call: Any = None) -> Optional[bool]:
554
+ return self.target.match(name, args, self.paths, call)
555
+
556
+ def bind(self, bindings: Dict[str, Any]) -> "_Pattern":
557
+ return _Pattern(_bound(self.target, bindings), self.paths)
558
+
559
+ def describe(self) -> str:
560
+ return self.target.describe()
561
+
562
+
563
+ class _AnyTool:
564
+ """A call to one of *tools* (``*`` globs), whatever its arguments."""
565
+
566
+ def __init__(self, tools: Tuple[str, ...]) -> None:
567
+ self.tools = tools
568
+
569
+ def match(self, name: str, args: Dict[str, Any]) -> bool:
570
+ return tool_matches(name, self.tools)
571
+
572
+ def describe(self) -> str:
573
+ return " or ".join(self.tools)
574
+
575
+
576
+ def _bound(target: Any, bindings: Dict[str, Any]) -> Any:
577
+ """*target* with its ``$variables`` replaced by values from *bindings*."""
578
+ import dataclasses
579
+ if hasattr(target, "targets"):
580
+ return type(target)(tuple(_bound(t, bindings) for t in target.targets))
581
+ with_ = dict(target.with_)
582
+ variables = {}
583
+ for arg, name in target.variables.items():
584
+ if name in bindings:
585
+ with_[arg] = bindings[name]
586
+ else:
587
+ variables[arg] = name
588
+ return dataclasses.replace(target, with_=with_, variables=variables)
589
+
590
+
591
+ def _matches(target: Any, paths: Paths, maybe: bool) -> Any:
592
+ from agentltl import Matches
593
+ return Matches(_Pattern(target, paths), maybe=maybe)
594
+
595
+
596
+ def _always(body: Any) -> Any:
597
+ """``G(body)``. With a past-only body each call is judged on its own: a rule broken
598
+ earlier (by an override) never blocks unrelated calls."""
599
+ from agentltl import Globally
600
+ return Globally(body)
601
+
602
+
603
+ def _never(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
604
+ """``G ¬matches(T)``; a possible match counts."""
605
+ from agentltl import Not
606
+ target = parse_target(raw["never"], where, with_=raw.get("with"), where_=raw.get("where"))
607
+
608
+ def explain(calls: List[Any]) -> Optional[str]:
609
+ c = calls[-1]
610
+ hit = target.match(c.name, c.args, paths)
611
+ if hit is True:
612
+ return f"{c.name} is not allowed: it matches {target.describe_hit(c.name, c.args, paths)}."
613
+ if hit is None:
614
+ return f"{c.name} may match {target.describe_hit(c.name, c.args, paths)}." + _MAYBE
615
+ return None
616
+
617
+ formula = _always(Not(_matches(target, paths, maybe=True)))
618
+ return formula, f"never {target.describe()}", explain, target.tools, target
619
+
620
+
621
+ def _before(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
622
+ """``G(matches(B) → Y(¬matches(S) S matches(A)))``: every B has an A after the last S
623
+ (``since``), or at all (``Y O matches(A)``). A possible B or S counts; a possible A does
624
+ not."""
625
+ from agentltl import Implies, Not, Once, Previous, Since
626
+ spec = raw["before"]
627
+ since = None
628
+ if isinstance(spec, list) and len(spec) == 2:
629
+ first, then = spec
630
+ elif isinstance(spec, dict) and {"first", "then"} <= set(spec) and not (
631
+ set(spec) - {"first", "then", "since"}):
632
+ first, then = spec["first"], spec["then"]
633
+ if spec.get("since") is not None:
634
+ since = parse_target(spec["since"], f"{where}.since")
635
+ else:
636
+ raise RuleError(f"{where}: expected [first, then] or {{first, then, since}}")
637
+ a = parse_target(first, f"{where}.first")
638
+ b = parse_target(then, f"{where}.then")
639
+ if _parts_with_variables(a) or _parts_with_variables(b):
640
+ return _before_same_value(a, b, since, paths, where)
641
+
642
+ def explain(calls: List[Any]) -> Optional[str]:
643
+ c = calls[-1]
644
+ if b.match(c.name, c.args, paths) is False:
645
+ return None
646
+ start = -1
647
+ if since is not None: # a call that may have been a `since` one resets, to be safe
648
+ start = max((i for i, p in enumerate(calls[:-1])
649
+ if since.match(p.name, p.args, paths, p) is not False), default=-1)
650
+ if any(a.matches(p.name, p.args, paths, p) for p in calls[start + 1:-1]):
651
+ return None
652
+ tail = ""
653
+ if since is not None and start >= 0:
654
+ tail = f" since the last {since.describe()} (call #{start + 1})"
655
+ return f"{c.name} needs {a.describe()} to have run first{tail}."
656
+
657
+ first_ok = _matches(a, paths, maybe=False)
658
+ if since is None:
659
+ earlier = Previous(Once(first_ok))
660
+ else:
661
+ earlier = Previous(Since(Not(_matches(since, paths, maybe=True)), first_ok))
662
+ formula = _always(Implies(_matches(b, paths, maybe=True), earlier))
663
+ label = f"{a.describe()} before {b.describe()}" + (f" since {since.describe()}" if since else "")
664
+ return (formula, label, explain, a.tools + b.tools, a, b) + ((since,) if since else ())
665
+
666
+
667
+ def _parts(target: Any) -> List[Any]:
668
+ return list(getattr(target, "targets", (target,)))
669
+
670
+
671
+ def _parts_with_variables(target: Any) -> List[Any]:
672
+ return [t for t in _parts(target) if t.variables]
673
+
674
+
675
+ def _before_same_value(a: Any, b: Any, since: Any, paths: Paths, where: str) -> Tuple[Any, ...]:
676
+ """``before`` whose two sides share ``$variables``:
677
+ ``G(∀x ∈ values of the call here. Y O matches(first with x))``.
678
+
679
+ A variable is implicitly universal: for every value x the call being judged has (as
680
+ ``then``), an earlier ``first`` call had it too. Several variables nest one ForAll each:
681
+ with single values (a chart and its version) the earlier call must have had that exact
682
+ combination; with lists, every combination.
683
+ """
684
+ from functools import reduce
685
+
686
+ from agentltl import ForAll, Once, Or, Previous
687
+
688
+ if since is not None:
689
+ raise RuleError(f"{where}: 'since' cannot be combined with $variables yet")
690
+ names = sorted({v for t in _parts(a) + _parts(b) for v in t.variables.values()})
691
+ if not names:
692
+ raise RuleError(f"{where}: use a $variable on both 'first' and 'then'")
693
+ for t in _parts(a) + _parts(b):
694
+ missing = [n for n in names if n not in t.variables.values()]
695
+ if missing:
696
+ raise RuleError(f"{where}: every target must bind "
697
+ f"{', '.join('$' + n for n in missing)} ({t.describe()} does not)")
698
+ for t in _parts(a):
699
+ if t.where or t.exists is not None:
700
+ raise RuleError(f"{where}.first: with a $variable, 'first' takes 'with' values only "
701
+ "(they are compared for equality)")
702
+
703
+ def values_here(var: str, call: Any) -> List[Any]:
704
+ values: List[Any] = []
705
+ for t in _parts(b):
706
+ if t.matches(call.name, call.args, paths, call):
707
+ for arg, name in t.variables.items():
708
+ if name == var:
709
+ value = call.args.get(arg)
710
+ values += value if isinstance(value, list) else [value]
711
+ return list(dict.fromkeys(x for x in values if x is not None))
712
+
713
+ def domain_of(var: str) -> Callable[..., List[Any]]:
714
+ def domain(trace: Any, metrics: Any, position: int) -> List[Any]:
715
+ call = trace.at(position)
716
+ return values_here(var, call) if call is not None else []
717
+ return domain
718
+
719
+ def explain(calls: List[Any]) -> Optional[str]:
720
+ c = calls[-1]
721
+ missing = []
722
+ for combo in _combinations({v: values_here(v, c) for v in names}):
723
+ wanted = [_bound(t, combo) for t in _parts(a)]
724
+ if not any(w.matches(p.name, p.args, paths, p) for p in calls[:-1] for w in wanted):
725
+ missing.append(combo)
726
+ if not missing:
727
+ return None
728
+ combo = missing[0]
729
+ bound = ", ".join(f"{k}={v!r}" for k, v in combo.items())
730
+ wanted = []
731
+ for t in _parts(a):
732
+ values = {**t.with_, **{arg: combo.get(name) for arg, name in t.variables.items()}}
733
+ shown = ", ".join(f"{k}={v!r}" for k, v in values.items())
734
+ for tool in t.tools:
735
+ if not any(tool_matches(p.name, (tool,)) for p in calls):
736
+ wanted.append(f"{tool} was never called")
737
+ else:
738
+ wanted.append(f"no earlier {tool} call had {shown}")
739
+ return f"for {bound}: {' or '.join(wanted)}."
740
+
741
+ body = reduce(Or, [Previous(Once(_matches(t, paths, maybe=False))) for t in _parts(a)])
742
+ for var in reversed(names):
743
+ body = ForAll(var, domain_of(var), body, description=f"values of ${var} in {b.describe()}")
744
+ label = f"{a.describe()} before {b.describe()}"
745
+ return _always(body), label, explain, a.tools + b.tools, a, b
746
+
747
+
748
+ def _combinations(values: Dict[str, List[Any]]) -> List[Dict[str, Any]]:
749
+ import itertools
750
+ names = list(values)
751
+ return [dict(zip(names, combo)) for combo in itertools.product(*(values[n] for n in names))]
752
+
753
+
754
+ def _require(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
755
+ """``G(called one of T's tools → matches(T))``; a possible match does not satisfy it."""
756
+ from agentltl import Implies, Matches
757
+ target = parse_target(raw["require"], where, with_=raw.get("with"), where_=raw.get("where"))
758
+ for part in getattr(target, "targets", (target,)):
759
+ if not part.with_ and not part.where:
760
+ raise RuleError(f"{where}: require needs 'with' or 'where' on every target "
761
+ f"(what the arguments of {part.describe()} must be)")
762
+
763
+ def explain(calls: List[Any]) -> Optional[str]:
764
+ c = calls[-1]
765
+ if not tool_matches(c.name, target.tools):
766
+ return None
767
+ hit = target.match(c.name, c.args, paths)
768
+ if hit is None:
769
+ return f"{c.name} must be called as {target.describe()}, which cannot be checked." + _MAYBE
770
+ if hit is False:
771
+ return f"{c.name} must be called as {target.describe()}."
772
+ return None
773
+
774
+ formula = _always(Implies(Matches(_AnyTool(target.tools)), _matches(target, paths, maybe=False)))
775
+ return formula, f"require {target.describe()}", explain, target.tools, target
776
+
777
+
778
+ def _at_most(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
779
+ """``G(matches(T) → fewer than n earlier calls matched T)``."""
780
+ from agentltl import CountBefore, Implies
781
+ spec = raw["at_most"]
782
+ if not isinstance(spec, dict) or set(spec) != {"call", "times"}:
783
+ raise RuleError(f"{where}: expected {{call: <target>, times: <n>}}")
784
+ times = spec["times"]
785
+ if not isinstance(times, int) or times < 0:
786
+ raise RuleError(f"{where}.times: expected a non-negative integer")
787
+ target = parse_target(spec["call"], f"{where}.call", with_=raw.get("with"),
788
+ where_=raw.get("where"))
789
+
790
+ def explain(calls: List[Any]) -> Optional[str]:
791
+ c = calls[-1]
792
+ if target.match(c.name, c.args, paths) is False:
793
+ return None
794
+ n = sum(1 for p in calls[:-1] if target.matches(p.name, p.args, paths, p))
795
+ if n >= times:
796
+ return f"{target.describe()} may run at most {times} time(s); it already ran {n}."
797
+ return None
798
+
799
+ formula = _always(Implies(_matches(target, paths, maybe=True),
800
+ CountBefore(_matches(target, paths, maybe=False), times, "<")))
801
+ return formula, f"at most {times} x {target.describe()}", explain, target.tools, target
802
+
803
+
804
+ def _ltl(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
805
+ from agentltl import parse
806
+ text = raw["ltl"]
807
+ if not isinstance(text, str):
808
+ raise RuleError(f"{where}: expected a formula string")
809
+ try:
810
+ formula = parse(text)
811
+ except Exception as exc:
812
+ raise RuleError(f"{where}: cannot parse formula: {exc}") from None
813
+ return formula, str(formula), None, tuple(sorted(_tools_in(formula)))
814
+
815
+
816
+ def _formula(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
817
+ formula = build_formula(raw["formula"], where)
818
+ return formula, str(formula), None, tuple(sorted(_tools_in(formula)))
819
+
820
+
821
+ def _finally(raw: Dict[str, Any], paths: Paths, where: str) -> Tuple[Any, ...]:
822
+ """Before finishing: T has run (``F matches(T)``), or has run after the last S
823
+ (``G(matches(S) → F matches(T))``). Judged on the finished trace only."""
824
+ from agentltl import Eventually, Globally, Implies
825
+ spec = raw["finally"]
826
+ since = None
827
+ if isinstance(spec, dict) and "call" in spec:
828
+ if set(spec) - {"call", "since"}:
829
+ raise RuleError(f"{where}: expected a target, or {{call: <target>, since: <target>}}")
830
+ call = parse_target(spec["call"], f"{where}.call")
831
+ if spec.get("since") is not None:
832
+ since = parse_target(spec["since"], f"{where}.since")
833
+ else:
834
+ call = parse_target(spec, where)
835
+ done = Eventually(_matches(call, paths, maybe=False))
836
+ formula = done if since is None else Globally(Implies(_matches(since, paths, maybe=True), done))
837
+
838
+ def explain(calls: List[Any]) -> Optional[str]:
839
+ start = -1
840
+ if since is not None:
841
+ start = max((i for i, p in enumerate(calls)
842
+ if since.match(p.name, p.args, paths, p) is not False), default=-2)
843
+ if start == -2:
844
+ return None # nothing that needs it happened
845
+ if any(call.matches(p.name, p.args, paths, p) for p in calls[start + 1:]):
846
+ return None
847
+ tail = f" after the last {since.describe()} (call #{start + 1})" if since else ""
848
+ return f"{call.describe()} has not run{tail}."
849
+
850
+ label = f"finally {call.describe()}" + (f" after {since.describe()}" if since else "")
851
+ return (formula, label, explain, call.tools, call) + ((since,) if since else ())
852
+
853
+
854
+ _BUILDERS = {"never": _never, "before": _before, "require": _require, "at_most": _at_most,
855
+ "finally": _finally, "ltl": _ltl, "formula": _formula}
856
+
857
+
858
+ def build_formula(spec: Any, where: str = "formula") -> Any:
859
+ """``{type: Before, args: {a: x, b: y}}`` → ``Before("x", "y")``, recursively."""
860
+ import dataclasses
861
+
862
+ import agentltl
863
+
864
+ if isinstance(spec, list):
865
+ return [build_formula(s, where) for s in spec]
866
+ if not (isinstance(spec, dict) and "type" in spec):
867
+ return spec
868
+ cls = getattr(agentltl, str(spec["type"]), None)
869
+ if not (isinstance(cls, type) and issubclass(cls, agentltl.Formula)) or cls is agentltl.Predicate:
870
+ raise RuleError(f"{where}: unknown formula type {spec['type']!r}")
871
+ args = spec.get("args") or {}
872
+ if not isinstance(args, dict):
873
+ raise RuleError(f"{where}: 'args' must be a mapping")
874
+ fields = {f.name for f in dataclasses.fields(cls)}
875
+ unknown = set(args) - fields
876
+ if unknown:
877
+ raise RuleError(f"{where}: {cls.__name__} has no argument(s) {sorted(unknown)}; "
878
+ f"expected {sorted(fields)}")
879
+ try:
880
+ return cls(**{k: build_formula(v, f"{where}.{k}") for k, v in args.items()})
881
+ except TypeError as exc:
882
+ raise RuleError(f"{where}: {exc}") from None
883
+
884
+
885
+ def _tools_in(formula: Any) -> set:
886
+ import dataclasses
887
+ out: set = set()
888
+ if isinstance(formula, (list, tuple)):
889
+ for f in formula:
890
+ out |= _tools_in(f)
891
+ return out
892
+ if not dataclasses.is_dataclass(formula):
893
+ return out
894
+ for f in dataclasses.fields(formula):
895
+ value = getattr(formula, f.name)
896
+ if f.name in ("tool", "a", "b", "tool_a", "tool_b", "target", "tools") and value:
897
+ out |= {value} if isinstance(value, str) else {v for v in value if isinstance(v, str)}
898
+ else:
899
+ out |= _tools_in(value)
900
+ return out
901
+
902
+
903
+ def _require_runtime_safe(formula: Any, rule_id: str) -> None:
904
+ """Reject formulas a check made call by call can't judge, as AgentLTL classifies them:
905
+ one that fails until some call happens (UNSAFE) would refuse every call before it, and
906
+ one that can't fail before the session ends (INERT) would never refuse anything."""
907
+ from agentltl.runtime_safety import RuntimeSafety, classify_runtime_safety
908
+ safety = classify_runtime_safety(formula).safety
909
+ if safety == RuntimeSafety.UNSAFE:
910
+ raise RuleError(
911
+ f"rule '{rule_id}': this formula fails until some call happens (called(\"x\") is "
912
+ "false until x runs), so every call before that would be refused. Say when it "
913
+ "applies, e.g. `G(now(\"deploy\") -> called(\"pytest\"))`, or use a rule kind such "
914
+ "as `before: {first: pytest, then: git_push}`.")
915
+ if safety == RuntimeSafety.INERT:
916
+ raise RuleError(
917
+ f"rule '{rule_id}': this is a liveness property: it can't fail before the session "
918
+ "ends (F, in_order: what it asks for may still happen), so it would never refuse a "
919
+ "call. Bound it, e.g. `within_steps(\"deploy\", \"notify\", 3)`, or say what must "
920
+ "not happen first, e.g. `before(\"pytest\", \"git_push\")`.")