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,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding – AgentLTL rules for coding agents, whatever the harness.
|
|
3
|
+
|
|
4
|
+
The rule language of AGENTLTL.yaml (never / before / require / at_most / ltl / formula),
|
|
5
|
+
the library of packaged rules, the guard that decides each tool call (shell command lines
|
|
6
|
+
checked as their structured calls), its per-session and per-project state, and the
|
|
7
|
+
``agentltl`` command, and what hooks do (:class:`Session`). A harness (the Claude Code
|
|
8
|
+
plugin, the GitHub Copilot CLI plugin, ...) supplies its hook I/O and a :class:`Harness`
|
|
9
|
+
description.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .guard import Guard, Verdict, lint, translator_for
|
|
13
|
+
from .harness import Harness, configure, current
|
|
14
|
+
from .pattern import Paths
|
|
15
|
+
from .rules import RuleFileError, RuleSet, library, load, loads, rule_files
|
|
16
|
+
from .session import Session
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.4"
|
|
19
|
+
|
|
20
|
+
__all__ = ["Guard", "Harness", "Paths", "RuleFileError", "RuleSet", "Session", "Verdict", "configure",
|
|
21
|
+
"current", "library", "lint", "load", "loads", "rule_files", "translator_for"]
|
agentltl_coding/cli.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/cli.py – the ``agentltl`` command.
|
|
3
|
+
|
|
4
|
+
agentltl validate [FILE] compile the rules; list them or the problems
|
|
5
|
+
agentltl check STEP... replay steps through the rules in a fresh session
|
|
6
|
+
agentltl translate COMMAND the structured calls a shell command stands for
|
|
7
|
+
agentltl tools [PATTERN] tool names and arguments rules can refer to
|
|
8
|
+
agentltl trace [--session ID] what the guard recorded: this session, and the project
|
|
9
|
+
agentltl reset [--session ID] forget this session's trace
|
|
10
|
+
agentltl reset --project forget the project's trace
|
|
11
|
+
agentltl library [NAME] packaged rules you can switch on, or one in full
|
|
12
|
+
agentltl use NAME... [--mode M] switch packaged rules on (unuse: off)
|
|
13
|
+
agentltl disable ID... switch single rules off by id (enable: back on)
|
|
14
|
+
agentltl memory scan [FILE...] statements in the agent's memory that could be rules
|
|
15
|
+
agentltl memory decline ID... don't propose these statements again (forget: undo)
|
|
16
|
+
|
|
17
|
+
``use``/``unuse``/``disable``/``enable`` edit the project's AGENTLTL.yaml (created if
|
|
18
|
+
missing), or the user's (see :class:`agentltl_coding.Harness`) with ``--user``. A harness
|
|
19
|
+
adds its own commands with ``main(argv, extend=...)``.
|
|
20
|
+
|
|
21
|
+
A ``check`` step is a shell command (``"git push -f"``) or another tool as
|
|
22
|
+
``'Edit {"file_path": ".env"}'``. Prefix a step with what you expect (``deny: git push``,
|
|
23
|
+
``allow: pytest``) and ``check`` exits 1 when a step does not do that. Steps that are
|
|
24
|
+
allowed count as executed for the steps after them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import fnmatch
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
37
|
+
|
|
38
|
+
from . import store
|
|
39
|
+
from .harness import current as _harness
|
|
40
|
+
from .pattern import Paths
|
|
41
|
+
from .rules import (
|
|
42
|
+
FILE_NAME,
|
|
43
|
+
MODES,
|
|
44
|
+
RuleFileError,
|
|
45
|
+
RuleSet,
|
|
46
|
+
find_rule_file,
|
|
47
|
+
library,
|
|
48
|
+
load,
|
|
49
|
+
rule_files,
|
|
50
|
+
user_rule_file,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_EXPECT = re.compile(r"^(allow|deny|ask|stop|note)\s*:\s*", re.I)
|
|
54
|
+
_TOOL_STEP = re.compile(r"^([A-Z][A-Za-z0-9_]*|mcp__[A-Za-z0-9_]+)\s+(\{.*\})\s*$", re.S)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: Optional[List[str]] = None,
|
|
58
|
+
extend: Optional[Callable[[Any, Dict[str, Callable]], None]] = None) -> int:
|
|
59
|
+
"""Run the ``agentltl`` command. *extend(subparsers, commands)* lets a harness add its
|
|
60
|
+
own subcommands (``commands[name] = handler(args) -> int``)."""
|
|
61
|
+
logging.getLogger("agentltl").setLevel(logging.ERROR)
|
|
62
|
+
parser = argparse.ArgumentParser(prog="agentltl", description=__doc__.split("\n\n")[0])
|
|
63
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
64
|
+
|
|
65
|
+
p = sub.add_parser("validate", help="compile the rule files and list the rules")
|
|
66
|
+
p.add_argument("files", nargs="*", help="rule files (default: the ones that apply here)")
|
|
67
|
+
|
|
68
|
+
p = sub.add_parser("check", help="replay steps through the rules")
|
|
69
|
+
p.add_argument("steps", nargs="+", metavar="STEP")
|
|
70
|
+
p.add_argument("--rules", action="append", default=[], metavar="FILE",
|
|
71
|
+
help="use only these rule files (repeatable)")
|
|
72
|
+
p.add_argument("--add", action="append", default=[], metavar="FILE",
|
|
73
|
+
help="add these rule files (e.g. a draft) to the ones that apply here")
|
|
74
|
+
p.add_argument("--auto", action="store_true", help="as if the agent ran in auto mode")
|
|
75
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
76
|
+
|
|
77
|
+
p = sub.add_parser("translate", help="show the structured calls of a shell command")
|
|
78
|
+
p.add_argument("command")
|
|
79
|
+
|
|
80
|
+
p = sub.add_parser("tools", help="tool names and arguments that rules can refer to")
|
|
81
|
+
p.add_argument("pattern", nargs="?", default="*",
|
|
82
|
+
help="glob on tool names, e.g. 'git_*' (default: all)")
|
|
83
|
+
|
|
84
|
+
for name in ("trace", "reset"):
|
|
85
|
+
p = sub.add_parser(name, help="show" if name == "trace" else "forget"
|
|
86
|
+
" what the guard recorded")
|
|
87
|
+
p.add_argument("--session", help="session id (default: the latest one here)")
|
|
88
|
+
if name == "reset":
|
|
89
|
+
p.add_argument("--project", action="store_true",
|
|
90
|
+
help="forget the project memory instead of the session's")
|
|
91
|
+
|
|
92
|
+
p = sub.add_parser("library", help="packaged rules you can switch on with `use`")
|
|
93
|
+
p.add_argument("name", nargs="?", help="show this one in full")
|
|
94
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
95
|
+
|
|
96
|
+
for name, what in (("use", "switch packaged rules on"), ("unuse", "switch packaged rules off"),
|
|
97
|
+
("disable", "switch rules off by id"), ("enable", "undo `disable`")):
|
|
98
|
+
p = sub.add_parser(name, help=what)
|
|
99
|
+
p.add_argument("names", nargs="+", metavar="ID" if "able" in name else "NAME")
|
|
100
|
+
p.add_argument("--user", action="store_true",
|
|
101
|
+
help=f"edit {_harness().user_file()} (every project) instead of this "
|
|
102
|
+
"project's")
|
|
103
|
+
if name == "use":
|
|
104
|
+
p.add_argument("--mode", choices=list(MODES), help="override the packs' modes")
|
|
105
|
+
p.add_argument("--from", dest="origin", metavar="ID", action="append",
|
|
106
|
+
help="the memory statement this enforces")
|
|
107
|
+
|
|
108
|
+
if _harness().memory is not None:
|
|
109
|
+
p = sub.add_parser("memory", help=f"find the rules in {_harness().agent}'s memory files")
|
|
110
|
+
msub = p.add_subparsers(dest="memory_cmd", required=True)
|
|
111
|
+
m = msub.add_parser("scan", help="statements that could become rules")
|
|
112
|
+
m.add_argument("files", nargs="*", help="only these files (default: the memory that "
|
|
113
|
+
"applies here)")
|
|
114
|
+
m.add_argument("--user", action="store_true", help="the user's own memory files")
|
|
115
|
+
m.add_argument("--all", action="store_true",
|
|
116
|
+
help="also statements already covered or declined")
|
|
117
|
+
m.add_argument("--json", action="store_true", help="machine-readable output")
|
|
118
|
+
for name, what in (("decline", "don't propose these statements again"),
|
|
119
|
+
("forget", "undo `decline`")):
|
|
120
|
+
m = msub.add_parser(name, help=what)
|
|
121
|
+
m.add_argument("ids", nargs="+", metavar="ID")
|
|
122
|
+
|
|
123
|
+
commands = dict(_COMMANDS)
|
|
124
|
+
if extend is not None:
|
|
125
|
+
extend(sub, commands)
|
|
126
|
+
args = parser.parse_args(argv)
|
|
127
|
+
try:
|
|
128
|
+
return commands[args.cmd](args)
|
|
129
|
+
except RuleFileError as exc:
|
|
130
|
+
print("Rule file problems:", file=sys.stderr)
|
|
131
|
+
for problem in exc.problems:
|
|
132
|
+
print(f" {problem}", file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _here() -> Tuple[str, str]:
|
|
137
|
+
cwd = os.getcwd()
|
|
138
|
+
env = _harness().project_env
|
|
139
|
+
return cwd, (os.environ.get(env) if env else None) or _git_root(cwd) or cwd
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _git_root(path: str) -> Optional[str]:
|
|
143
|
+
while True:
|
|
144
|
+
if os.path.exists(os.path.join(path, ".git")):
|
|
145
|
+
return path
|
|
146
|
+
parent = os.path.dirname(path)
|
|
147
|
+
if parent == path:
|
|
148
|
+
return None
|
|
149
|
+
path = parent
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ruleset(files: List[str], add: List[str] = ()) -> RuleSet:
|
|
153
|
+
cwd, root = _here()
|
|
154
|
+
files = list(files) or rule_files(cwd, root)
|
|
155
|
+
files += [f for f in add if f not in files]
|
|
156
|
+
return load(files, Paths(cwd, root))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── commands ──────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
162
|
+
ruleset = _ruleset(args.files)
|
|
163
|
+
if not ruleset.files:
|
|
164
|
+
print(f"No AGENTLTL.yaml here (nor {_harness().user_file()}). Nothing is enforced.")
|
|
165
|
+
return 0
|
|
166
|
+
print(f"OK: {len(ruleset.rules)} rule(s) from {', '.join(ruleset.files)}")
|
|
167
|
+
for r in ruleset.rules:
|
|
168
|
+
memory = ", project memory" if r.scope == "project" else ""
|
|
169
|
+
came = "".join(f", from {o.get('file', o.get('id'))}" + (f":{o['line']}" if o.get("line") else "")
|
|
170
|
+
for o in r.origins)
|
|
171
|
+
print(f" {r.id} [{r.mode}{memory}] ({_origin(r.source)}{came}): {r.summary}")
|
|
172
|
+
if r.why:
|
|
173
|
+
print(f" why: {r.why}")
|
|
174
|
+
_warn(ruleset)
|
|
175
|
+
s = ruleset.settings
|
|
176
|
+
print(f"settings: default mode {s.mode}, default memory {s.scope}, retries {s.retries}, "
|
|
177
|
+
"unparseable commands: "
|
|
178
|
+
f"{s.unparseable_interactive} (interactive) / {s.unparseable_auto} (auto mode)")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check(args: argparse.Namespace) -> int:
|
|
183
|
+
from .guard import Guard
|
|
184
|
+
|
|
185
|
+
cwd, root = _here()
|
|
186
|
+
ruleset = _ruleset(args.rules, args.add)
|
|
187
|
+
guard = Guard(ruleset, Paths(cwd, root))
|
|
188
|
+
guard.restore({}, {})
|
|
189
|
+
if not args.json:
|
|
190
|
+
_warn(ruleset)
|
|
191
|
+
rows, failed = [], False
|
|
192
|
+
for i, raw in enumerate(args.steps, 1):
|
|
193
|
+
expect = None
|
|
194
|
+
m = _EXPECT.match(raw)
|
|
195
|
+
if m:
|
|
196
|
+
expect, raw = m.group(1).lower(), raw[m.end():]
|
|
197
|
+
tool, tool_input = _step(raw)
|
|
198
|
+
verdict = guard.decide(tool, tool_input, auto=args.auto)
|
|
199
|
+
got = verdict.action
|
|
200
|
+
if got == "none":
|
|
201
|
+
got = "note" if verdict.context else "allow"
|
|
202
|
+
if got in ("allow", "note"):
|
|
203
|
+
guard.record(tool, tool_input, f"step{i}", "")
|
|
204
|
+
ok = expect is None or expect == got or (expect == "allow" and got == "note")
|
|
205
|
+
failed |= not ok
|
|
206
|
+
rows.append({"step": i, "call": raw, "result": got, "expected": expect, "ok": ok,
|
|
207
|
+
"rule": verdict.rule, "calls": verdict.calls,
|
|
208
|
+
"reason": verdict.reason or verdict.context})
|
|
209
|
+
if args.json:
|
|
210
|
+
print(json.dumps(rows, indent=2))
|
|
211
|
+
return 1 if failed else 0
|
|
212
|
+
for row in rows:
|
|
213
|
+
mark = "" if row["expected"] is None else (" ok" if row["ok"] else
|
|
214
|
+
f" EXPECTED {row['expected'].upper()}")
|
|
215
|
+
rule = f" [{row['rule']}]" if row["rule"] else ""
|
|
216
|
+
print(f"{row['step']:>2}. {row['result'].upper():<5} {row['call']}{rule}{mark}")
|
|
217
|
+
names = [c["tool_name"] for c in row["calls"]]
|
|
218
|
+
if names and names != [row["call"].split()[0]]:
|
|
219
|
+
print(f" calls: {', '.join(names)}")
|
|
220
|
+
if row["result"] not in ("allow",) and row["reason"]:
|
|
221
|
+
for line in row["reason"].splitlines():
|
|
222
|
+
print(f" | {line}")
|
|
223
|
+
return 1 if failed else 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _origin(source: str) -> str:
|
|
227
|
+
"""Where a rule comes from, in the words `disable` / `unuse` / editing need."""
|
|
228
|
+
if source.startswith("library:") or source == "built-in":
|
|
229
|
+
return source
|
|
230
|
+
if source == _harness().user_file():
|
|
231
|
+
return f"user file {source}"
|
|
232
|
+
return f"file {source}" if source else "?"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _warn(ruleset: RuleSet) -> None:
|
|
236
|
+
from .guard import lint
|
|
237
|
+
for warning in lint(ruleset):
|
|
238
|
+
print(f"WARNING {warning}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _step(raw: str) -> Tuple[str, Dict[str, Any]]:
|
|
242
|
+
m = _TOOL_STEP.match(raw.strip())
|
|
243
|
+
if not m:
|
|
244
|
+
word, _, rest = raw.strip().partition(" ")
|
|
245
|
+
if word in _harness().tool_aliases and rest.strip().startswith("{"):
|
|
246
|
+
m = re.match(r"(\S+)\s+(\{.*\})\s*$", raw.strip(), re.S)
|
|
247
|
+
if m:
|
|
248
|
+
try:
|
|
249
|
+
return m.group(1), json.loads(m.group(2))
|
|
250
|
+
except ValueError as exc:
|
|
251
|
+
raise SystemExit(f"step {raw!r}: invalid JSON input: {exc}")
|
|
252
|
+
return "Bash", {"command": raw}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _translate(args: argparse.Namespace) -> int:
|
|
256
|
+
from cli_to_tools import TranslationError
|
|
257
|
+
|
|
258
|
+
from .guard import translator_for
|
|
259
|
+
try:
|
|
260
|
+
calls = translator_for(_ruleset([])).translate(args.command)
|
|
261
|
+
except TranslationError as exc:
|
|
262
|
+
print(f"Cannot translate: {exc}", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
for c in calls:
|
|
265
|
+
flags = [k for k in ("conditional", "repeated") if c.meta.get(k)]
|
|
266
|
+
extra = f" ({', '.join(flags)})" if flags else ""
|
|
267
|
+
print(f"{c.name} {json.dumps(c.args)}{extra}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _tools(args: argparse.Namespace) -> int:
|
|
272
|
+
from .guard import translator_for
|
|
273
|
+
schemas = translator_for(_ruleset([])).registry.tool_schemas()
|
|
274
|
+
shown = 0
|
|
275
|
+
for s in schemas:
|
|
276
|
+
if not fnmatch.fnmatchcase(s["name"], args.pattern):
|
|
277
|
+
continue
|
|
278
|
+
props = s.get("parameters", {}).get("properties", {})
|
|
279
|
+
params = ", ".join(f"{k}: {v.get('type', 'string')}" for k, v in props.items())
|
|
280
|
+
print(f"{s['name']}({params})")
|
|
281
|
+
shown += 1
|
|
282
|
+
if shown == 0:
|
|
283
|
+
print(f"No tool matches {args.pattern!r}. Commands without a spec become a tool named "
|
|
284
|
+
"after the executable with a single 'argv' list.")
|
|
285
|
+
print("\nThe agent's own tools keep their names (Edit, Write, Read, WebFetch, mcp__...) "
|
|
286
|
+
"with their input fields as arguments (Edit/Write: file_path). Shell output "
|
|
287
|
+
"redirections appear as 'redirect_to'.")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _calls(state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
292
|
+
"""The recorded calls of a saved state (AgentLTL 0.2's, or the older ``trace``)."""
|
|
293
|
+
return list(state.get("completed_tool_calls") or state.get("trace") or [])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _session(args: argparse.Namespace) -> Optional[str]:
|
|
297
|
+
sid = args.session or store.latest_session(_here()[1])
|
|
298
|
+
if sid is None:
|
|
299
|
+
print("No recorded session for this project.", file=sys.stderr)
|
|
300
|
+
return sid
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _trace(args: argparse.Namespace) -> int:
|
|
304
|
+
root = _here()[1]
|
|
305
|
+
project = _calls(store.read_project(root))
|
|
306
|
+
print(f"project {root}: {len(project)} recorded call(s), across sessions")
|
|
307
|
+
sid = args.session or store.latest_session(root)
|
|
308
|
+
if sid is None:
|
|
309
|
+
print("No recorded session for this project.")
|
|
310
|
+
return 0
|
|
311
|
+
state = store.read(sid)
|
|
312
|
+
trace = _calls(state)
|
|
313
|
+
print(f"session {sid}: {len(trace)} recorded call(s)")
|
|
314
|
+
for i, c in enumerate(trace[-50:], max(1, len(trace) - 49)):
|
|
315
|
+
args_ = {k: v for k, v in (c.get("arguments") or {}).items()
|
|
316
|
+
if v not in (None, False, [], "")}
|
|
317
|
+
print(f" {i:>3}. {c.get('tool_name')} {json.dumps(args_)[:120]}")
|
|
318
|
+
decisions = state.get("decisions") or []
|
|
319
|
+
if decisions:
|
|
320
|
+
print(f"\nlast {min(10, len(decisions))} intervention(s):")
|
|
321
|
+
for d in decisions[-10:]:
|
|
322
|
+
what = d["input"].get("command") or d["input"].get("file_path") or ""
|
|
323
|
+
print(f" {d['action'].upper():<5} [{d['rule'] or 'not checked'}] {d['tool']} {str(what)[:100]}")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _reset(args: argparse.Namespace) -> int:
|
|
328
|
+
if args.project:
|
|
329
|
+
root = _here()[1]
|
|
330
|
+
store.reset_project(root)
|
|
331
|
+
print(f"Project {root}: trace and counters cleared.")
|
|
332
|
+
return 0
|
|
333
|
+
sid = _session(args)
|
|
334
|
+
if sid is None:
|
|
335
|
+
return 1
|
|
336
|
+
store.reset(sid)
|
|
337
|
+
print(f"Session {sid}: trace and counters cleared.")
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _library(args: argparse.Namespace) -> int:
|
|
342
|
+
packs = library()
|
|
343
|
+
try:
|
|
344
|
+
used = set(_ruleset([]).used)
|
|
345
|
+
except RuleFileError:
|
|
346
|
+
used = set()
|
|
347
|
+
if args.name:
|
|
348
|
+
if args.name not in packs:
|
|
349
|
+
print(f"No library rule {args.name!r}. `agentltl library` lists them.", file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
with open(packs[args.name]["path"], encoding="utf-8") as fh:
|
|
352
|
+
print(fh.read(), end="")
|
|
353
|
+
return 0
|
|
354
|
+
packs = {n: p for n, p in packs.items() if _harness().matches(p.get("harnesses"))}
|
|
355
|
+
if args.json:
|
|
356
|
+
print(json.dumps([{"name": n, "summary": p.get("summary", ""), "tags": p.get("tags", []),
|
|
357
|
+
"in_use": n in used,
|
|
358
|
+
"rules": [r.get("id") for r in p.get("rules") or []]}
|
|
359
|
+
for n, p in packs.items()], indent=2))
|
|
360
|
+
return 0
|
|
361
|
+
width = max(map(len, packs), default=0)
|
|
362
|
+
for name, pack in packs.items():
|
|
363
|
+
mark = "on " if name in used else " "
|
|
364
|
+
tags = ", ".join(pack.get("tags") or [])
|
|
365
|
+
print(f" {mark} {name:<{width}} {pack.get('summary', '')} [{tags}]")
|
|
366
|
+
print("\nSwitch one on: agentltl use NAME (--user for every project; --mode to override)")
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _target_file(user: bool) -> str:
|
|
371
|
+
if user:
|
|
372
|
+
return user_rule_file() or _harness().user_file()
|
|
373
|
+
cwd, root = _here()
|
|
374
|
+
return find_rule_file(cwd, root) or os.path.join(root, FILE_NAME)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _edit_list(args: argparse.Namespace) -> int:
|
|
378
|
+
from .edit import read_list, write_list
|
|
379
|
+
from .rules import _use_name
|
|
380
|
+
|
|
381
|
+
path = _target_file(args.user)
|
|
382
|
+
key = "use" if args.cmd in ("use", "unuse") else "disable"
|
|
383
|
+
items = read_list(path, key)
|
|
384
|
+
if args.cmd == "use":
|
|
385
|
+
packs = library()
|
|
386
|
+
unknown = [n for n in args.names if n not in packs]
|
|
387
|
+
if unknown:
|
|
388
|
+
import difflib
|
|
389
|
+
for n in unknown:
|
|
390
|
+
close = difflib.get_close_matches(n, packs, n=1)
|
|
391
|
+
print(f"No library rule {n!r}" + (f" (did you mean {close[0]!r}?)" if close else ""),
|
|
392
|
+
file=sys.stderr)
|
|
393
|
+
return 1
|
|
394
|
+
other = [n for n in args.names if not _harness().matches(packs[n].get("harnesses"))]
|
|
395
|
+
if other:
|
|
396
|
+
for n in other:
|
|
397
|
+
print(f"Library rule {n!r} is for {', '.join(packs[n]['harnesses'])}, not "
|
|
398
|
+
f"{_harness().name}.", file=sys.stderr)
|
|
399
|
+
return 1
|
|
400
|
+
items = [i for i in items if _use_name(i) not in args.names]
|
|
401
|
+
extra = {k: v for k, v in (("mode", args.mode), ("from", _from(args.origin))) if v}
|
|
402
|
+
items += [{n: dict(extra)} if extra else n for n in args.names]
|
|
403
|
+
elif args.cmd == "unuse":
|
|
404
|
+
items = [i for i in items if _use_name(i) not in args.names]
|
|
405
|
+
elif args.cmd == "disable":
|
|
406
|
+
items += [n for n in args.names if n not in items]
|
|
407
|
+
else:
|
|
408
|
+
missing = [n for n in args.names if n not in items]
|
|
409
|
+
if missing:
|
|
410
|
+
print(f"Not disabled in {path}: {', '.join(missing)}", file=sys.stderr)
|
|
411
|
+
items = [i for i in items if i not in args.names]
|
|
412
|
+
write_list(path, key, items)
|
|
413
|
+
try:
|
|
414
|
+
ruleset = load([path], Paths(*_here()))
|
|
415
|
+
except RuleFileError as exc:
|
|
416
|
+
print(f"{path} has problems now:", file=sys.stderr)
|
|
417
|
+
for problem in exc.problems:
|
|
418
|
+
print(f" {problem}", file=sys.stderr)
|
|
419
|
+
return 2
|
|
420
|
+
shown = ", ".join(_use_name(i) for i in items) or "(none)"
|
|
421
|
+
n = sum(r.source != "built-in" for r in ruleset.rules)
|
|
422
|
+
print(f"{path}: {key} = {shown}; {n} rule(s) in this file now.")
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _memory(args: argparse.Namespace) -> int:
|
|
427
|
+
from . import memory
|
|
428
|
+
|
|
429
|
+
root = _here()[1]
|
|
430
|
+
if args.memory_cmd in ("decline", "forget"):
|
|
431
|
+
have = getattr(memory, args.memory_cmd)(root, args.ids)
|
|
432
|
+
print(f"Declined for {root}: {', '.join(have) or '(none)'}")
|
|
433
|
+
return 0
|
|
434
|
+
from .guard import translator_for
|
|
435
|
+
try:
|
|
436
|
+
ruleset = _ruleset([])
|
|
437
|
+
except RuleFileError:
|
|
438
|
+
ruleset = None
|
|
439
|
+
translator = translator_for(ruleset or RuleSet())
|
|
440
|
+
result = memory.scan(root, ruleset, translator, user_only=args.user, files=args.files)
|
|
441
|
+
if not args.all:
|
|
442
|
+
result["statements"] = [s for s in result["statements"]
|
|
443
|
+
if s["status"] == "new" and s["candidate"]]
|
|
444
|
+
if args.json:
|
|
445
|
+
print(json.dumps(result, indent=2))
|
|
446
|
+
return 0
|
|
447
|
+
c = result["counts"]
|
|
448
|
+
print(f"{len(result['sources'])} memory file(s), {c['statements']} statement(s): "
|
|
449
|
+
f"{c['candidates']} new that may be rules, {c['covered']} covered by a rule, "
|
|
450
|
+
f"{c['declined']} declined." + ("" if args.all else " --all lists every statement."))
|
|
451
|
+
last = None
|
|
452
|
+
for st in result["statements"]:
|
|
453
|
+
if st["file"] != last:
|
|
454
|
+
last = st["file"]
|
|
455
|
+
print(f"\n{last} (rules go in the {st['target']} file)")
|
|
456
|
+
mark = "" if st["status"] == "new" else f" [{st['status']}]"
|
|
457
|
+
mark += "" if st["candidate"] or not args.all else " [not a candidate]"
|
|
458
|
+
text = " ⏎ ".join(line.strip() for line in st["text"].splitlines())
|
|
459
|
+
text = text if len(text) <= 110 else text[:107] + "..."
|
|
460
|
+
print(f" {st['id']} :{st['line']}{mark} {text}")
|
|
461
|
+
for cmd in st["commands"]:
|
|
462
|
+
names = ", ".join(c["name"] for c in cmd.get("calls", []))
|
|
463
|
+
spec = f" (no spec: {', '.join(cmd['needs_spec'])})" if cmd.get("needs_spec") else ""
|
|
464
|
+
print(f" `{cmd['text']}` -> {names}{spec}")
|
|
465
|
+
return 0
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _from(ids: Optional[List[str]]) -> Any:
|
|
469
|
+
if not ids:
|
|
470
|
+
return None
|
|
471
|
+
return ids[0] if len(ids) == 1 else list(ids)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
_COMMANDS = {"validate": _validate, "check": _check, "translate": _translate, "tools": _tools,
|
|
475
|
+
"trace": _trace, "reset": _reset, "library": _library, "use": _edit_list,
|
|
476
|
+
"unuse": _edit_list, "disable": _edit_list, "enable": _edit_list,
|
|
477
|
+
"memory": _memory}
|
|
478
|
+
|
|
479
|
+
__all__ = ["main"]
|
|
480
|
+
|
|
481
|
+
if __name__ == "__main__":
|
|
482
|
+
sys.exit(main())
|
agentltl_coding/edit.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/edit.py – change the ``use:`` and ``disable:`` lists of a rule file in place.
|
|
3
|
+
|
|
4
|
+
Only the block of the key being changed is rewritten; the rules, comments and layout of the
|
|
5
|
+
rest of the file are kept. A change that would leave the file invalid is undone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, List
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
# The same file serves every harness (Claude Code, Copilot CLI...), so it names none.
|
|
17
|
+
NEW_FILE = ("# AGENTLTL.yaml – rules AgentLTL enforces on every tool call of your coding agent.\n"
|
|
18
|
+
"# `agentltl library` lists packaged rules; add your own under `rules:`.\n"
|
|
19
|
+
"\nrules: []\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_list(path: str, key: str) -> List[Any]:
|
|
23
|
+
"""The current value of top-level *key* (a list), or [] when absent."""
|
|
24
|
+
if not os.path.exists(path):
|
|
25
|
+
return []
|
|
26
|
+
with open(path, encoding="utf-8") as fh:
|
|
27
|
+
data = yaml.safe_load(fh) or {}
|
|
28
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
29
|
+
return list(value) if isinstance(value, list) else []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_list(path: str, key: str, items: List[Any]) -> None:
|
|
33
|
+
"""Set top-level *key* to *items* (removing the key when empty), keeping the rest."""
|
|
34
|
+
text = NEW_FILE
|
|
35
|
+
if os.path.exists(path):
|
|
36
|
+
with open(path, encoding="utf-8") as fh:
|
|
37
|
+
text = fh.read()
|
|
38
|
+
lines = text.splitlines(keepends=True)
|
|
39
|
+
if lines and not lines[-1].endswith("\n"):
|
|
40
|
+
lines[-1] += "\n"
|
|
41
|
+
block = _render(key, items)
|
|
42
|
+
start = next((i for i, line in enumerate(lines) if re.match(rf"{key}\s*:", line)), None)
|
|
43
|
+
if start is not None:
|
|
44
|
+
end = start + 1
|
|
45
|
+
while end < len(lines) and (not lines[end].strip() or lines[end][0] in " \t-#"):
|
|
46
|
+
end += 1
|
|
47
|
+
while end > start + 1 and (not lines[end - 1].strip() or lines[end - 1].startswith("#")):
|
|
48
|
+
end -= 1 # leave blank lines and comments of the next section
|
|
49
|
+
lines[start:end] = block
|
|
50
|
+
else:
|
|
51
|
+
at = next((i for i, line in enumerate(lines) if re.match(r"rules\s*:", line)), len(lines))
|
|
52
|
+
lines[at:at] = block + (["\n"] if block and at < len(lines) else [])
|
|
53
|
+
new = "".join(lines)
|
|
54
|
+
yaml.safe_load(new) # raises before anything is written
|
|
55
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
56
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
57
|
+
fh.write(new)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _render(key: str, items: List[Any]) -> List[str]:
|
|
61
|
+
if not items:
|
|
62
|
+
return []
|
|
63
|
+
out = [f"{key}:\n"]
|
|
64
|
+
for item in items:
|
|
65
|
+
flow = yaml.safe_dump([item], default_flow_style=True, sort_keys=False).strip()
|
|
66
|
+
out.append(f" - {flow[1:-1]}\n")
|
|
67
|
+
return out
|