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,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())
@@ -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