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,291 @@
1
+ """
2
+ agentltl_coding/pattern.py – which calls a rule is talking about.
3
+
4
+ A target names one or more tools and optionally narrows them by argument:
5
+
6
+ git_push # any git push
7
+ [Edit, Write] # either tool
8
+ {tool: git_push, with: {force: true}} # exact argument values
9
+ {tool: [Edit, Write], where: {file_path: "**/.env"}} # glob patterns
10
+
11
+ ``with`` compares values for equality; when the call's value is a list, an expected scalar
12
+ must be one of its elements (``rm a b`` matches ``with: {paths: a}``). ``where`` matches
13
+ string values against glob patterns (``*`` also crosses ``/``); a path is tried as written,
14
+ resolved against the working directory, relative to the project root, and, for a pattern
15
+ without ``/``, by its basename. The key ``"*"`` stands for any argument:
16
+
17
+ {tool: [Edit, Write, rm, mv, cat], where: {"*": "**/.env"}}
18
+
19
+ ``exists: true`` (or ``false``) additionally requires a value that ``where`` matched to be a
20
+ path that exists (or does not) at the moment of the check, so "never modify an existing
21
+ migration" does not also catch creating a new one:
22
+
23
+ {tool: Write, where: {file_path: "migrations/*"}, exists: true}
24
+
25
+ ``succeeded: true`` (or ``false``) requires a call that ran to have succeeded (or failed):
26
+ a shell command's exit status, as the harness recorded it. A call that has not run yet may
27
+ or may not succeed, so for it the answer is "maybe":
28
+
29
+ before: {first: {tool: pytest, succeeded: true}, then: git_push}
30
+
31
+ A ``with`` value written ``$name`` is a variable, not a literal: ``before`` uses it to tie
32
+ two calls together (``{tool: Read, with: {file_path: $f}}`` before
33
+ ``{tool: Edit, with: {file_path: $f}}`` means "the same file").
34
+
35
+ Path arguments (``file_path``, ``paths``, ``redirect_to``, ...) are made absolute before
36
+ rules see them, so ``cat a.py`` and ``Read /proj/a.py`` name the same file.
37
+
38
+ A list of targets matches when any of them does:
39
+
40
+ [{tool: Write, where: {file_path: "*.lock"}}, {tool: rm, where: {paths: "*.lock"}}]
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import fnmatch
46
+ import os
47
+ from dataclasses import dataclass, field
48
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
49
+
50
+ from cli_to_tools import PATH_NAMES, UNKNOWN_PATHS, Paths, normalize_paths
51
+ from cli_to_tools._effects import REDIRECT_KEYS as _REDIRECT_KEYS
52
+ from cli_to_tools._effects import absolute as _absolute
53
+ from cli_to_tools._effects import strings as _strings
54
+ from cli_to_tools._effects import unknown_path_keys as _unknown_path_keys
55
+
56
+
57
+ class RuleError(ValueError):
58
+ """A rule file entry that cannot be compiled."""
59
+
60
+
61
+ # Arguments holding file paths (cli-to-tools' default names, the harness tools' file_path,
62
+ # and the redirections). A call whose file arguments are only known at run time carries
63
+ # UNKNOWN_PATHS: True when any path argument may be (`xargs rm`), else the list of those that
64
+ # hold one (`rm $UNSET` gives ["paths"]). A condition on such an argument is answered "maybe".
65
+ PATH_KEYS = tuple(dict.fromkeys(PATH_NAMES + _REDIRECT_KEYS))
66
+
67
+
68
+ @dataclass
69
+ class Target:
70
+ tools: Tuple[str, ...]
71
+ with_: Dict[str, Any] = field(default_factory=dict)
72
+ where: Dict[str, Tuple[str, ...]] = field(default_factory=dict)
73
+ exists: Optional[bool] = None
74
+ variables: Dict[str, str] = field(default_factory=dict) # argument -> variable name
75
+ succeeded: Optional[bool] = None
76
+
77
+ def matches(self, name: str, args: Optional[Dict[str, Any]], paths: Paths,
78
+ call: Any = None) -> bool:
79
+ return self.match(name, args, paths, call) is True
80
+
81
+ def match(self, name: str, args: Optional[Dict[str, Any]], paths: Paths,
82
+ call: Any = None) -> Optional[bool]:
83
+ """True, False, or None for "maybe": a path condition on a call whose file
84
+ arguments are only known at run time (see UNKNOWN_PATHS), or a ``succeeded``
85
+ condition on a call that has not run. *call* is the recorded call, if it ran."""
86
+ if not tool_matches(name, self.tools):
87
+ return False
88
+ if self.succeeded is not None:
89
+ ran = _status(call)
90
+ if ran is None:
91
+ return None if self.match_args(name, args, paths) is not False else False
92
+ if (ran == 0) != self.succeeded:
93
+ return False
94
+ return self.match_args(name, args, paths)
95
+
96
+ def match_args(self, name: str, args: Optional[Dict[str, Any]], paths: Paths) -> Optional[bool]:
97
+ """The argument conditions alone (``with``, ``where``, ``exists``)."""
98
+ args = args or {}
99
+ unknown = args.get(UNKNOWN_PATHS) or []
100
+ maybe = False
101
+ for key, expected in self.with_.items():
102
+ actual = args.get(key)
103
+ if not _equal(expected, actual) and not (
104
+ key in PATH_KEYS and isinstance(expected, str)
105
+ and _equal(_absolute(expected, paths), actual)):
106
+ if _unknown(unknown, key):
107
+ maybe = True
108
+ continue
109
+ return False
110
+ hits: List[str] = []
111
+ for key, patterns in self.where.items():
112
+ values = _strings(list(args.values()) if key == "*" else args.get(key))
113
+ found = [v for v in values if any(_glob(v, p, paths) for p in patterns)]
114
+ if not found:
115
+ if _unknown(unknown, key):
116
+ maybe = True
117
+ continue
118
+ return False
119
+ hits += found
120
+ if self.exists is not None:
121
+ if not self.where: # no glob narrowed it: judge the path arguments themselves
122
+ hits = [v for key in PATH_KEYS for v in _strings(args.get(key))]
123
+ if not any(os.path.exists(_absolute(v, paths)) == self.exists for v in hits):
124
+ if not unknown:
125
+ return False
126
+ maybe = True
127
+ return None if maybe else True
128
+
129
+ def describe_hit(self, name: str, args: Optional[Dict[str, Any]], paths: Paths) -> str:
130
+ return self.describe()
131
+
132
+ def describe(self) -> str:
133
+ tools = " or ".join(self.tools)
134
+ parts = [f"{k}={v!r}" for k, v in self.with_.items()]
135
+ parts += [f"{k}=${v}" for k, v in self.variables.items()]
136
+ parts += [f"{k} matching {' or '.join(v)}" for k, v in self.where.items()]
137
+ if self.exists is not None:
138
+ parts.append("existing path" if self.exists else "new path")
139
+ if self.succeeded is not None:
140
+ parts.append("succeeded" if self.succeeded else "failed")
141
+ return f"{tools} ({', '.join(parts)})" if parts else tools
142
+
143
+
144
+ @dataclass
145
+ class AnyTarget:
146
+ """Several targets; a call matches when it matches any of them."""
147
+
148
+ targets: Tuple[Target, ...]
149
+
150
+ @property
151
+ def tools(self) -> Tuple[str, ...]:
152
+ return tuple(dict.fromkeys(t for target in self.targets for t in target.tools))
153
+
154
+ def matches(self, name: str, args: Optional[Dict[str, Any]], paths: Paths,
155
+ call: Any = None) -> bool:
156
+ return self.match(name, args, paths, call) is True
157
+
158
+ def match(self, name: str, args: Optional[Dict[str, Any]], paths: Paths,
159
+ call: Any = None) -> Optional[bool]:
160
+ results = [t.match(name, args, paths, call) for t in self.targets]
161
+ if True in results:
162
+ return True
163
+ return None if None in results else False
164
+
165
+ def describe(self) -> str:
166
+ return " or ".join(t.describe() for t in self.targets)
167
+
168
+ def describe_hit(self, name: str, args: Optional[Dict[str, Any]], paths: Paths) -> str:
169
+ """The alternatives a call matches (or may match), not every one."""
170
+ hits = [t for t in self.targets if t.match(name, args, paths) is not False]
171
+ return " or ".join(t.describe() for t in hits) or self.describe()
172
+
173
+
174
+ def parse_target(spec: Any, where: str, *, with_: Any = None, where_: Any = None) -> Any:
175
+ """Build a target from its YAML form; ``with_``/``where_`` are rule-level extras."""
176
+ if isinstance(spec, list) and any(isinstance(s, dict) for s in spec):
177
+ return AnyTarget(tuple(parse_target(s, f"{where}[{i}]", with_=with_, where_=where_)
178
+ for i, s in enumerate(spec)))
179
+ if isinstance(spec, dict):
180
+ unknown = set(spec) - {"tool", "with", "where", "exists", "succeeded"}
181
+ if unknown:
182
+ raise RuleError(f"{where}: unknown key(s) {sorted(unknown)} "
183
+ "(expected tool, with, where, exists, succeeded)")
184
+ exists = spec.get("exists")
185
+ if exists is not None and not isinstance(exists, bool):
186
+ raise RuleError(f"{where}.exists: must be true or false")
187
+ succeeded = spec.get("succeeded")
188
+ if succeeded is not None and not isinstance(succeeded, bool):
189
+ raise RuleError(f"{where}.succeeded: must be true or false")
190
+ if "tool" not in spec:
191
+ raise RuleError(f"{where}: a target needs 'tool'")
192
+ tools = _names(spec["tool"], where)
193
+ w = {**_mapping(spec.get("with"), f"{where}.with"), **_mapping(with_, "with")}
194
+ g = {**_patterns(spec.get("where"), f"{where}.where"), **_patterns(where_, "where")}
195
+ return _with_variables(Target(tools, w, g, exists, succeeded=succeeded))
196
+ return _with_variables(Target(_names(spec, where), _mapping(with_, "with"),
197
+ _patterns(where_, "where")))
198
+
199
+
200
+ def _status(call: Any) -> Optional[int]:
201
+ """The exit status of a call that ran: 0 when recorded without one (calls were only
202
+ recorded once they succeeded before statuses were); None for a call not run yet."""
203
+ raw = getattr(call, "raw", None)
204
+ if not isinstance(raw, dict) or ("result" not in raw and "status" not in raw):
205
+ return None
206
+ return int(raw.get("status") or 0)
207
+
208
+
209
+ def tool_matches(name: str, tools: Tuple[str, ...]) -> bool:
210
+ """Whether *name* is one of *tools*; a name with ``*`` is a glob (``kubectl_*``)."""
211
+ return name in tools or any("*" in t and fnmatch.fnmatchcase(name, t) for t in tools)
212
+
213
+
214
+ def _with_variables(target: Target) -> Target:
215
+ """Move ``$name`` values out of ``with`` into ``variables``."""
216
+ for key, value in list(target.with_.items()):
217
+ if isinstance(value, str) and value.startswith("$") and value[1:].isidentifier():
218
+ target.variables[key] = value[1:]
219
+ del target.with_[key]
220
+ return target
221
+
222
+
223
+ # Redirections are written out in the command line itself: `xargs` or `find -exec` can add
224
+ # arguments to a command, never a redirection to it (see _unknown).
225
+
226
+
227
+ def _unknown(unknown: Any, key: str) -> bool:
228
+ """Whether argument *key* may hold a path only known at run time (see UNKNOWN_PATHS)."""
229
+ if unknown is True:
230
+ return key == "*" or (key in PATH_KEYS and key not in _REDIRECT_KEYS)
231
+ return bool(unknown) and (key == "*" or key in unknown)
232
+
233
+
234
+ def _names(spec: Any, where: str) -> Tuple[str, ...]:
235
+ names = [spec] if isinstance(spec, str) else spec
236
+ if not isinstance(names, (list, tuple)) or not names or not all(
237
+ isinstance(n, str) and n for n in names):
238
+ raise RuleError(f"{where}: expected a tool name or a list of tool names, got {spec!r}")
239
+ return tuple(names)
240
+
241
+
242
+ def _mapping(spec: Any, where: str) -> Dict[str, Any]:
243
+ if spec is None:
244
+ return {}
245
+ if not isinstance(spec, dict):
246
+ raise RuleError(f"{where}: expected a mapping of argument name to value")
247
+ return dict(spec)
248
+
249
+
250
+ def _patterns(spec: Any, where: str) -> Dict[str, Tuple[str, ...]]:
251
+ out: Dict[str, Tuple[str, ...]] = {}
252
+ for key, value in _mapping(spec, where).items():
253
+ values = [value] if isinstance(value, str) else value
254
+ if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
255
+ raise RuleError(f"{where}.{key}: expected a glob pattern or a list of them")
256
+ out[key] = tuple(values)
257
+ return out
258
+
259
+
260
+ def _equal(expected: Any, actual: Any) -> bool:
261
+ if isinstance(actual, list) and not isinstance(expected, list):
262
+ return any(_equal(expected, a) for a in actual)
263
+ if isinstance(expected, bool) or isinstance(actual, bool):
264
+ return bool(actual) == bool(expected) if actual is not None else expected is False
265
+ if isinstance(expected, (int, float)) and isinstance(actual, str):
266
+ return actual.strip() == str(expected)
267
+ return expected == actual
268
+
269
+
270
+ def _glob(value: str, pattern: str, paths: Paths) -> bool:
271
+ for candidate in _forms(value, paths, basename="/" not in pattern):
272
+ if fnmatch.fnmatchcase(candidate, pattern):
273
+ return True
274
+ return False
275
+
276
+
277
+ def _forms(value: str, paths: Paths, basename: bool) -> Sequence[str]:
278
+ forms = [value]
279
+ if value and not value.startswith(("-", "http://", "https://")):
280
+ absolute = _absolute(value, paths)
281
+ forms.append(absolute)
282
+ if paths.root and (absolute == paths.root or absolute.startswith(paths.root + os.sep)):
283
+ forms.append(os.path.relpath(absolute, paths.root))
284
+ if basename:
285
+ forms.append(os.path.basename(absolute))
286
+ return forms
287
+
288
+
289
+ def unknown_path_keys(args: Dict[str, Any]) -> List[str]:
290
+ """The path arguments holding a value only known at run time."""
291
+ return _unknown_path_keys(args, PATH_KEYS)