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.
- agentltl_coding/__init__.py +21 -0
- agentltl_coding/cli.py +482 -0
- agentltl_coding/edit.py +67 -0
- agentltl_coding/guard.py +461 -0
- agentltl_coding/harness.py +110 -0
- agentltl_coding/library/ask-before-creating-credentials.yaml +9 -0
- agentltl_coding/library/ask-before-discarding-work.yaml +12 -0
- agentltl_coding/library/ask-before-infra-changes.yaml +9 -0
- agentltl_coding/library/ask-before-installing.yaml +12 -0
- agentltl_coding/library/ask-before-reading-secret-stores.yaml +35 -0
- agentltl_coding/library/ask-before-recursive-delete.yaml +7 -0
- agentltl_coding/library/devops-secrets.yaml +10 -0
- agentltl_coding/library/no-claude-coauthor.yaml +11 -0
- agentltl_coding/library/no-codex-coauthor.yaml +11 -0
- agentltl_coding/library/no-copilot-coauthor.yaml +11 -0
- agentltl_coding/library/no-env-dumps.yaml +18 -0
- agentltl_coding/library/no-force-push.yaml +7 -0
- agentltl_coding/library/no-leaky-debug.yaml +13 -0
- agentltl_coding/library/no-push-to-main.yaml +7 -0
- agentltl_coding/library/no-secrets-in-commands.yaml +21 -0
- agentltl_coding/library/no-skipping-secret-scans.yaml +9 -0
- agentltl_coding/library/no-vibe-coauthor.yaml +11 -0
- agentltl_coding/library/protect-env-files.yaml +9 -0
- agentltl_coding/library/protect-secret-files.yaml +53 -0
- agentltl_coding/library/read-before-overwrite.yaml +11 -0
- agentltl_coding/library/subagents-on-sonnet.yaml +12 -0
- agentltl_coding/library/tests-before-finishing.yaml +13 -0
- agentltl_coding/library/tests-before-push.yaml +14 -0
- agentltl_coding/memory.py +390 -0
- agentltl_coding/pattern.py +291 -0
- agentltl_coding/rules.py +920 -0
- agentltl_coding/scan.py +54 -0
- agentltl_coding/session.py +149 -0
- agentltl_coding/store.py +123 -0
- agentltl_coding-0.2.4.dist-info/METADATA +129 -0
- agentltl_coding-0.2.4.dist-info/RECORD +40 -0
- agentltl_coding-0.2.4.dist-info/WHEEL +5 -0
- agentltl_coding-0.2.4.dist-info/entry_points.txt +2 -0
- agentltl_coding-0.2.4.dist-info/licenses/LICENSE +21 -0
- 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)
|