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,10 @@
1
+ summary: "Bundle: every secrets rule (credential files, environment dumps, secret stores, new credentials, secrets on the command line, leaky debug output, skipped scans)."
2
+ tags: [secrets, devops, bundle]
3
+ include:
4
+ - protect-secret-files
5
+ - no-env-dumps
6
+ - ask-before-reading-secret-stores
7
+ - ask-before-creating-credentials
8
+ - no-secrets-in-commands
9
+ - no-leaky-debug
10
+ - no-skipping-secret-scans
@@ -0,0 +1,11 @@
1
+ summary: Claude never signs commits, tags or notes (no Co-Authored-By, no Claude author).
2
+ harnesses: [claude-code]
3
+ tags: [git]
4
+ rules:
5
+ - id: no-claude-coauthor
6
+ never: [git_commit, git_commit_tree, git_tag, git_notes, git_merge, git_revert, git_cherry_pick]
7
+ where:
8
+ "*": ["*[Cc]o-[Aa]uthored-[Bb]y*", "*noreply@anthropic.com*", "*[Gg]enerated with*[Cc]laude*",
9
+ "*[Cc]laude <*", "user.name=*[Cc]laude*", "user.email=*anthropic.com*"]
10
+ why: Claude should not appear in git history.
11
+ fix: Drop the Co-Authored-By / "Generated with Claude Code" lines and commit as the user.
@@ -0,0 +1,11 @@
1
+ summary: Codex never signs commits, tags or notes (no Co-authored-by, no "Generated with Codex").
2
+ harnesses: [codex]
3
+ tags: [git]
4
+ rules:
5
+ - id: no-codex-coauthor
6
+ never: [git_commit, git_commit_tree, git_tag, git_notes, git_merge, git_revert, git_cherry_pick]
7
+ where:
8
+ "*": ["*[Cc]o-[Aa]uthored-[Bb]y*", "*noreply@openai.com*", "*[Gg]enerated with*[Cc]odex*",
9
+ "*[Cc]odex <*", "user.name=*[Cc]odex*", "user.email=*openai.com*"]
10
+ why: Codex should not appear in git history.
11
+ fix: Drop the Co-authored-by / "Generated with Codex" lines and commit as the user.
@@ -0,0 +1,11 @@
1
+ summary: Copilot never signs commits, tags or notes (no Co-authored-by, no Copilot author).
2
+ harnesses: [copilot-cli]
3
+ tags: [git]
4
+ rules:
5
+ - id: no-copilot-coauthor
6
+ never: [git_commit, git_commit_tree, git_tag, git_notes, git_merge, git_revert, git_cherry_pick]
7
+ where:
8
+ "*": ["*[Cc]o-[Aa]uthored-[Bb]y*", "*Copilot@users.noreply.github.com*",
9
+ "*[Gg]enerated *[Cc]opilot*", "*[Cc]opilot <*", "user.name=*[Cc]opilot*"]
10
+ why: Copilot should not appear in git history.
11
+ fix: Drop the Co-authored-by / "Generated by Copilot" lines and commit as the user.
@@ -0,0 +1,18 @@
1
+ summary: Never print the environment or a secret-looking variable (env, printenv, export -p, echo $TOKEN, docker inspect...).
2
+ tags: [secrets, devops]
3
+ rules:
4
+ - id: no-env-dumps
5
+ never:
6
+ - {tool: [env, set], with: {argv: []}}
7
+ - printenv
8
+ - {tool: export, with: {print: true}}
9
+ - {tool: declare, with: {print: true}}
10
+ - {tool: declare, with: {export: true}}
11
+ - {tool: [echo, printf], where: {"*": ["*$*TOKEN*", "*$*SECRET*", "*$*PASSWORD*", "*$*PASSWD*",
12
+ "*$*API_KEY*", "*$*PRIVATE_KEY*", "*$*CREDENTIAL*"]}}
13
+ - {tool: [kubectl_exec, docker_exec], where: {command: [env, printenv]}}
14
+ - docker_inspect
15
+ - docker_compose_config
16
+ why: Environment variables often hold credentials; printing them puts the secrets into the conversation.
17
+ fix: Check that a variable is set without printing it, e.g. `test -n "$VAR" && echo set`.
18
+ mode: block
@@ -0,0 +1,7 @@
1
+ summary: Never force-push (--force-with-lease is still allowed).
2
+ tags: [git]
3
+ rules:
4
+ - id: no-force-push
5
+ never: {tool: git_push, with: {force: true}}
6
+ why: Force-pushing rewrites shared history and can destroy other people's work.
7
+ fix: Pull and merge or rebase, then push normally, or use --force-with-lease.
@@ -0,0 +1,13 @@
1
+ summary: Warn before debug output that prints credentials (curl -v with auth headers, kubectl -v=6+, aws/az --debug, gcloud --log-http).
2
+ tags: [secrets, devops]
3
+ rules:
4
+ - id: no-leaky-debug
5
+ never:
6
+ - {tool: curl, with: {verbose: true}, where: {header: ["*uthorization*", "*oken*", "*-Key*"]}}
7
+ - {tool: "kubectl_*", where: {verbosity: ["6", "7", "8", "9", "1?"]}}
8
+ - {tool: "aws_*", with: {debug: true}}
9
+ - {tool: "az_*", with: {debug: true}}
10
+ - {tool: "gcloud_*", with: {log_http: true}}
11
+ why: At these levels the tools print request headers, including bearer tokens and signatures.
12
+ fix: Drop the debug flag, or lower the verbosity (kubectl -v=5 or less).
13
+ mode: warn
@@ -0,0 +1,7 @@
1
+ summary: Never push to main or master by name (a bare `git push` while on main is not seen).
2
+ tags: [git]
3
+ rules:
4
+ - id: no-push-to-main
5
+ never: {tool: git_push, where: {refspecs: [main, master, "*:main", "*:master"]}}
6
+ why: main only changes through reviewed pull requests.
7
+ fix: Push to a feature branch and open a pull request.
@@ -0,0 +1,21 @@
1
+ summary: Never put a secret value on the command line (passwords in flags, bearer headers, credentials in URLs).
2
+ tags: [secrets, devops]
3
+ rules:
4
+ - id: no-secrets-in-commands
5
+ never:
6
+ - {tool: curl, where: {header: ["*uthorization: Bearer *", "*uthorization: Basic *", "*-Token: *", "*-Key: *"]}}
7
+ - {tool: curl, where: {user: "*:*"}}
8
+ - {tool: "*", where: {"*": ["*://*:*@*"]}}
9
+ - {tool: mysql, where: {password_suffix: "?*"}}
10
+ - {tool: redis_cli, where: {password: "?*"}}
11
+ - {tool: docker_login, where: {password: "?*"}}
12
+ - {tool: kubectl_create, where: {from_literal: "*=*"}}
13
+ - {tool: [aws_secretsmanager_create_secret, aws_secretsmanager_put_secret_value], where: {secret_string: "?*"}}
14
+ - {tool: aws_ssm_put_parameter, with: {type: SecureString}}
15
+ - {tool: [az_keyvault_secret_set], where: {value: "?*"}}
16
+ - {tool: [gh_secret_set], where: {body: "?*"}}
17
+ - {tool: [az_login, az_acr_login, kubectl_config_set_credentials], where: {password: "?*"}}
18
+ - {tool: kubectl_config_set_credentials, where: {token: "?*"}}
19
+ why: Command lines end up in the conversation, the shell history and process listings.
20
+ fix: Read the secret from a file or an environment variable the tool supports (--password-stdin, --from-file, -F secret=@file), or let the user run the command.
21
+ mode: block
@@ -0,0 +1,9 @@
1
+ summary: Ask before skipping git hooks (--no-verify, which also skips secret scanners) or force-adding ignored files.
2
+ tags: [secrets, git, devops]
3
+ rules:
4
+ - id: no-skipping-secret-scans
5
+ never:
6
+ - {tool: [git_commit, git_push], with: {no_verify: true}}
7
+ - {tool: git_add, with: {force: true}}
8
+ why: Pre-commit hooks often run secret scanners, and ignored files often are the secrets.
9
+ mode: ask
@@ -0,0 +1,11 @@
1
+ summary: Mistral Vibe never signs commits, tags or notes (no Co-Authored-By, no "Generated by Mistral Vibe").
2
+ harnesses: [mistral-vibe]
3
+ tags: [git]
4
+ rules:
5
+ - id: no-vibe-coauthor
6
+ never: [git_commit, git_commit_tree, git_tag, git_notes, git_merge, git_revert, git_cherry_pick]
7
+ where:
8
+ "*": ["*[Cc]o-[Aa]uthored-[Bb]y*", "*vibe@mistral.ai*", "*[Gg]enerated by*[Vv]ibe*",
9
+ "*[Mm]istral [Vv]ibe <*", "user.name=*[Vv]ibe*", "user.email=*mistral.ai*"]
10
+ why: Mistral Vibe should not appear in git history.
11
+ fix: Drop the Co-Authored-By / "Generated by Mistral Vibe" lines and commit as the user.
@@ -0,0 +1,9 @@
1
+ summary: Never read, write, copy or delete .env files (.env.example is fine).
2
+ tags: [secrets]
3
+ rules:
4
+ - id: protect-env-files
5
+ never: {tool: "*", where: {"*": [".env", ".env.local", ".env.*.local", ".env.production",
6
+ ".env.staging", ".env.development"]}}
7
+ why: .env files hold credentials; reading one puts secrets into the conversation.
8
+ fix: Ask the user for the variable names you need, or read .env.example.
9
+ mode: stop
@@ -0,0 +1,53 @@
1
+ summary: Never read, copy, send or edit credential files (.env, keys, cloud and tool credentials, shell history).
2
+ tags: [secrets, devops]
3
+ rules:
4
+ - id: protect-secret-files
5
+ never:
6
+ tool: "*"
7
+ where:
8
+ "*":
9
+ # project files
10
+ - ".env"
11
+ - ".env.local"
12
+ - ".env.*.local"
13
+ - ".env.production"
14
+ - ".env.staging"
15
+ - ".env.development"
16
+ - ".envrc"
17
+ - "*.tfvars"
18
+ - "terraform.tfstate*"
19
+ - "*.pem"
20
+ - "*.key"
21
+ - "*.p12"
22
+ - "*.pfx"
23
+ - "*.jks"
24
+ - "credentials.json"
25
+ - "service-account*.json"
26
+ - "**/secrets.yaml"
27
+ - "**/secrets.yml"
28
+ - ".htpasswd"
29
+ # home directory
30
+ - "**/.ssh/id_*"
31
+ - "**/.gnupg/*"
32
+ - "**/.aws/credentials"
33
+ - "**/.config/gcloud/*"
34
+ - "**/.azure/*"
35
+ - "**/.kube/config"
36
+ - "**/.docker/config.json"
37
+ - "**/.npmrc"
38
+ - "**/.pypirc"
39
+ - "**/.netrc"
40
+ - "**/.git-credentials"
41
+ - "**/.config/gh/hosts.yml"
42
+ - "**/.vault-token"
43
+ - "**/.pgpass"
44
+ - "**/.my.cnf"
45
+ - "**/.claude/.credentials.json"
46
+ - "**/.bash_history"
47
+ - "**/.zsh_history"
48
+ # system
49
+ - "/etc/shadow"
50
+ - "/proc/*/environ"
51
+ why: These files hold credentials. Reading one puts the secret into the conversation and the transcript; copying or sending one leaks it.
52
+ fix: Ask the user for what you need (a variable name, a non-secret value), or use the example file (.env.example).
53
+ mode: stop
@@ -0,0 +1,11 @@
1
+ summary: Read an existing file before overwriting or editing it in place.
2
+ tags: [editing]
3
+ rules:
4
+ - id: read-before-overwrite
5
+ before:
6
+ first: {tool: Read, with: {file_path: $f}}
7
+ then: [{tool: Write, with: {file_path: $f}, exists: true},
8
+ {tool: [sed, perl], with: {in_place: true, paths: $f}, exists: true},
9
+ {tool: "*", with: {overwrite_to: $f}, exists: true}]
10
+ why: Overwriting a file you have not read loses whatever was in it.
11
+ fix: Read the file first, then write it.
@@ -0,0 +1,12 @@
1
+ summary: Subagents run on Sonnet (or Haiku), never on a bigger model.
2
+ harnesses: [claude-code]
3
+ tags: [cost, agents]
4
+ rules:
5
+ - id: subagents-on-sonnet
6
+ require: {tool: [Agent, Task], where: {model: [sonnet, haiku]}}
7
+ why: Subagents should run on a cheaper model than the main session.
8
+ fix: Pass model "sonnet" (or "haiku") to the Agent tool.
9
+ - id: subagents-on-sonnet-no-fork
10
+ never: {tool: [Agent, Task], with: {subagent_type: fork}}
11
+ why: A fork always runs on the main session's model, whatever model you pass.
12
+ fix: Start a fresh subagent (a subagent_type other than fork) with model "sonnet".
@@ -0,0 +1,13 @@
1
+ summary: Before finishing a turn that edited code, run the tests (pytest, npm/cargo/go/make test, tox, jest).
2
+ tags: [testing]
3
+ rules:
4
+ - id: tests-before-finishing
5
+ finally:
6
+ call:
7
+ - [pytest, tox, nox, jest, vitest]
8
+ - {tool: [npm, pnpm, yarn, cargo, go, make, just], with: {argv: test}}
9
+ - {tool: [uv, poetry], with: {argv: pytest}}
10
+ - {tool: npx, with: {argv: jest}}
11
+ since: [Edit, Write, MultiEdit, NotebookEdit]
12
+ why: Work handed back untested is work the user has to test.
13
+ fix: Run the test suite now. If the project tests differently, replace this rule with your own command.
@@ -0,0 +1,14 @@
1
+ summary: Run the tests, and see them pass, after your last edit before pushing (pytest, npm/cargo/go/make test, tox, jest).
2
+ tags: [testing, git]
3
+ rules:
4
+ - id: tests-before-push
5
+ before:
6
+ first:
7
+ - {tool: [pytest, tox, nox, jest, vitest], succeeded: true}
8
+ - {tool: [npm, pnpm, yarn, cargo, go, make, just], with: {argv: test}, succeeded: true}
9
+ - {tool: [uv, poetry], with: {argv: pytest}, succeeded: true}
10
+ - {tool: npx, with: {argv: jest}, succeeded: true}
11
+ then: git_push
12
+ since: [Edit, Write, MultiEdit, NotebookEdit]
13
+ why: Untested pushes break CI for everyone.
14
+ fix: Run the test suite after your last edit and make it pass, then push. If your project tests differently, replace this rule with your own command.
@@ -0,0 +1,390 @@
1
+ """
2
+ agentltl_coding/memory.py – find the statements in an agent's memory that could be rules.
3
+
4
+ Coding agents keep instructions in markdown: ``CLAUDE.md`` and auto memory for Claude Code,
5
+ ``.github/copilot-instructions.md`` and ``AGENTS.md`` for Copilot. The harness says which
6
+ files apply (:attr:`agentltl_coding.Harness.memory`). :func:`scan` splits them into
7
+ statements (a bullet, a paragraph, a table row, or one auto-memory file), gives each a stable
8
+ id, and adds what code can tell for sure: the modal words it uses, and the commands it quotes,
9
+ translated into the calls a rule would name. Deciding which statements are rules is left to
10
+ the agent (the plugin's ``import`` skill); this module only makes that repeatable:
11
+
12
+ - ``id``: a hash of the statement's text, so it survives edits elsewhere in the file;
13
+ - ``status``: ``covered`` (a rule in force has ``from: <id>``), ``declined`` (the user said
14
+ no, see :func:`decline`) or ``new``;
15
+ - ``target``: the rule file a rule from it belongs in, the project's or the user's.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fnmatch
21
+ import hashlib
22
+ import os
23
+ import re
24
+ import shutil
25
+ from dataclasses import asdict, dataclass, field
26
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
27
+
28
+ import yaml
29
+
30
+ from . import store
31
+ from .harness import current as _harness
32
+
33
+ PROJECT, USER = "project", "user"
34
+ _SKIP_DIRS = {".git", "node_modules", "vendor", "third_party", ".venv", "venv", "dist", "build",
35
+ "__pycache__", ".tox", ".mypy_cache", ".pytest_cache", "target", "site-packages"}
36
+ _MAX_DEPTH = 6
37
+ _MAX_CONTEXT = 1500
38
+
39
+ # A statement is a *candidate* rule when it is phrased as an instruction (a modal word, and an
40
+ # imperative opening, capitals, or a "you"/"Claude") about an action a tool call performs.
41
+ _STRONG = re.compile(r"\b(never|always|must(?: not)?|do not|don't|avoid|prefer|ask (?:me|the user|"
42
+ r"first|before)|instead of|first|only|stop)\b", re.I)
43
+ _ACTION = re.compile(r"\b(commit|push|pull|merge|rebase|install|uninstall|delet\w*|remov\w*|rm|"
44
+ r"edit\w*|writ\w*|modif\w*|run\w*|execut\w*|deploy\w*|apply|read|kill|restart|"
45
+ r"launch|overwrit\w*|upload|publish|tag|reset|force|sudo|chmod|clone|call\w*|"
46
+ r"invoke|pip|npm|docker|git|kubectl|terraform|helm|curl)\b", re.I)
47
+ _IMPERATIVE = re.compile(r"^(never|always|do not|don't|avoid|prefer|use|run|edit|ask|keep|make|"
48
+ r"write|check|read|commit|push|stop|only|before|after|when|if|no)\b", re.I)
49
+ _ADDRESSED = re.compile(r"\b(you|claude|copilot|codex|we|i)\b", re.I)
50
+ _MODAL = re.compile(r"\b(never|always|only|must(?: not)?|do not|don't|avoid|prefer(?:s|red)?|"
51
+ r"ask(?: me)?(?: first| before)?|before|after|unless|except|instead of)\b", re.I)
52
+ _EMPHASIS = re.compile(r"\b(NEVER|ALWAYS|ONLY|MUST|DO NOT|DON'T|NOT)\b")
53
+ _SPAN = re.compile(r"`([^`\n]+)`")
54
+ _COMMAND_WORD = re.compile(r"^[a-z][a-z0-9_.+-]*$")
55
+ _BULLET = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+")
56
+
57
+
58
+ @dataclass
59
+ class Source:
60
+ path: str
61
+ kind: str # claude-md | rules | auto-memory | agents-md | instructions
62
+ target: str # PROJECT or USER
63
+
64
+
65
+ @dataclass
66
+ class Statement:
67
+ id: str
68
+ file: str
69
+ line: int
70
+ text: str
71
+ section: str = ""
72
+ context: str = "" # an auto-memory file's body
73
+ code: str = "" # the code blocks that follow the statement
74
+ modality: List[str] = field(default_factory=list)
75
+ emphasis: bool = False
76
+ commands: List[Dict[str, Any]] = field(default_factory=list)
77
+ target: str = PROJECT
78
+ candidate: bool = False
79
+ status: str = "new"
80
+ rules: List[str] = field(default_factory=list)
81
+
82
+
83
+ # ── where memory lives ────────────────────────────────────────────────────────
84
+
85
+ def sources(root: str, user_only: bool = False, home: Optional[str] = None) -> List[Source]:
86
+ """The memory files that apply in project *root*, as the harness finds them, each once.
87
+ With *user_only*, only the user's own."""
88
+ find = _harness().memory
89
+ if find is None:
90
+ return []
91
+ seen, unique = set(), []
92
+ for s in find(root, user_only, home or os.path.expanduser("~")):
93
+ real = os.path.realpath(s.path)
94
+ if os.path.isfile(s.path) and real not in seen:
95
+ seen.add(real)
96
+ unique.append(s)
97
+ return unique
98
+
99
+
100
+ def files(folder: str, pattern: str) -> List[str]:
101
+ """Files under *folder* whose name matches *pattern*, in a stable order."""
102
+ found = []
103
+ for base, dirs, names in os.walk(folder):
104
+ dirs.sort()
105
+ found += [os.path.join(base, n) for n in sorted(names) if fnmatch.fnmatch(n, pattern)]
106
+ return found
107
+
108
+
109
+ def nested(root: str, name: str) -> List[str]:
110
+ """Files called *name* below *root* (not *root*'s own), outside dependencies and
111
+ submodules."""
112
+ submodules = set()
113
+ gitmodules = os.path.join(root, ".gitmodules")
114
+ if os.path.isfile(gitmodules):
115
+ with open(gitmodules, encoding="utf-8", errors="replace") as fh:
116
+ submodules = {os.path.normpath(os.path.join(root, m)) for m in
117
+ re.findall(r"^\s*path\s*=\s*(.+?)\s*$", fh.read(), re.M)}
118
+ found = []
119
+ for base, dirs, names in os.walk(root):
120
+ depth = os.path.relpath(base, root).count(os.sep)
121
+ dirs[:] = sorted(d for d in dirs if d not in _SKIP_DIRS and not d.startswith(".")
122
+ and os.path.join(base, d) not in submodules and depth < _MAX_DEPTH)
123
+ if base != root and name in names:
124
+ found.append(os.path.join(base, name))
125
+ return found
126
+
127
+
128
+ # ── splitting into statements ─────────────────────────────────────────────────
129
+
130
+ def statement_id(text: str) -> str:
131
+ norm = re.sub(r"[\s*_`>#|]+", " ", text).strip().lower()
132
+ return hashlib.sha1(norm.encode()).hexdigest()[:10]
133
+
134
+
135
+ def split(text: str, path: str = "", kind: str = "claude-md") -> List[Statement]:
136
+ """The statements of one memory file, in order."""
137
+ if kind == "auto-memory":
138
+ one = _auto_memory_statement(text, path)
139
+ if one:
140
+ return [one]
141
+ out: List[Statement] = []
142
+ for line, body, section, code in _blocks(text):
143
+ if len(re.findall(r"\w+", body)) < 3 and not _SPAN.search(body):
144
+ continue
145
+ out.append(Statement(statement_id(body), path, line, body, section, code=code))
146
+ return out
147
+
148
+
149
+ def _auto_memory_statement(text: str, path: str) -> Optional[Statement]:
150
+ """An auto-memory file is one statement: its ``description``, with the body as context."""
151
+ m = re.match(r"---\s*\n(.*?)\n---\s*\n?", text, re.S)
152
+ if not m:
153
+ return None
154
+ try:
155
+ meta = yaml.safe_load(m.group(1)) or {}
156
+ except yaml.YAMLError:
157
+ return None
158
+ if not isinstance(meta, dict):
159
+ return None
160
+ description = str(meta.get("description") or meta.get("name") or "").strip()
161
+ if not description:
162
+ return None
163
+ body = text[m.end():].strip()
164
+ kind = (meta.get("metadata") or {}).get("type") if isinstance(meta.get("metadata"), dict) \
165
+ else meta.get("type")
166
+ code = "\n".join(re.findall(r"^(?:`{3,}|~{3,})[^\n]*\n(.*?)^(?:`{3,}|~{3,})", body, re.S | re.M))
167
+ return Statement(statement_id(description), path, 1, description,
168
+ section=str(kind or ""), context=body[:_MAX_CONTEXT], code=code)
169
+
170
+
171
+ def _blocks(text: str) -> Iterator[Tuple[int, str, str, str]]:
172
+ """(line, text, heading path, code) for each bullet, paragraph or table row.
173
+
174
+ A code block belongs to the statement just before it in the same section. A code block
175
+ with no such statement is a statement of its own: its text is the code.
176
+ """
177
+ lines = text.splitlines()
178
+ if lines and lines[0].strip() == "---": # frontmatter of a rules file
179
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
180
+ if end is not None:
181
+ lines = [""] * (end + 1) + lines[end + 1:]
182
+ headings: List[str] = []
183
+ out: List[List[Any]] = [] # [line, [text lines], section, [code]]
184
+ open_ = False # is out[-1] still being written?
185
+ fence: Optional[str] = None
186
+ fence_line, code = 0, [] # type: int, List[str]
187
+
188
+ for n, raw in enumerate(lines, 1):
189
+ stripped = raw.strip()
190
+ section = " > ".join(headings)
191
+ if fence is not None:
192
+ if not stripped.startswith(fence):
193
+ code.append(raw)
194
+ continue
195
+ fence = None
196
+ block = "\n".join(code)
197
+ if out and out[-1][2] == section and block.strip():
198
+ out[-1][3].append(block)
199
+ elif block.strip():
200
+ out.append([fence_line, [block], section, [block]])
201
+ open_ = False
202
+ continue
203
+ m = re.match(r"(`{3,}|~{3,})", stripped)
204
+ if m:
205
+ fence, fence_line, code = m.group(1), n, []
206
+ continue
207
+ if not stripped or stripped.startswith("<!--"):
208
+ open_ = False
209
+ continue
210
+ h = re.match(r"(#{1,6})\s+(.*)", stripped)
211
+ if h:
212
+ open_ = False
213
+ headings = headings[:len(h.group(1)) - 1] + [h.group(2).strip("# ")]
214
+ continue
215
+ if stripped.startswith("|"):
216
+ open_ = False
217
+ cells = [c.strip() for c in stripped.strip("|").split("|")]
218
+ if all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c):
219
+ continue # the separator row
220
+ if n < len(lines) and re.match(r"\s*\|[\s:|-]+\|\s*$", lines[n]):
221
+ continue # the header row
222
+ out.append([n, [" | ".join(c for c in cells if c)], section, []])
223
+ continue
224
+ b = _BULLET.match(raw)
225
+ if b and not b.group(1):
226
+ out.append([n, [raw[b.end():]], section, []])
227
+ open_ = True
228
+ elif open_:
229
+ out[-1][1].append(raw[b.end():] if b else stripped)
230
+ else:
231
+ out.append([n, [stripped], section, []])
232
+ open_ = True
233
+ for line, parts, section, codes in out:
234
+ text_ = parts[0] if codes and parts == codes[:1] else " ".join(x.strip() for x in parts)
235
+ yield line, text_.strip(), section, "\n".join(codes)
236
+
237
+
238
+ # ── hints ─────────────────────────────────────────────────────────────────────
239
+
240
+ def commands_in(statement: Statement, translator: Any = None) -> List[Dict[str, Any]]:
241
+ """The commands *statement* quotes (inline code, and lines of the code that follows it),
242
+ each with the calls it translates to. ``needs_spec`` marks a call with no spec (only
243
+ ``argv``): a rule can't name its options until a spec is added under ``tools:``."""
244
+ candidates = [s.strip() for s in _SPAN.findall(statement.text + "\n" + statement.context)]
245
+ for line in statement.code.splitlines()[:40]:
246
+ line = re.sub(r"^\$\s+", "", line.strip())
247
+ if line and not line.startswith("#"):
248
+ candidates.append(line)
249
+ out: List[Dict[str, Any]] = []
250
+ seen, shapes = set(), set()
251
+ for text in candidates:
252
+ first = text.split()[0] if text.split() else ""
253
+ if text in seen or not _COMMAND_WORD.match(first) or len(out) >= 8:
254
+ continue
255
+ if not (_known(first, translator) or ("." not in first and shutil.which(first))):
256
+ continue # a name, a script or a word, not a command
257
+ if " " not in text and not _known(first, translator):
258
+ continue
259
+ seen.add(text)
260
+ entry: Dict[str, Any] = {"text": text}
261
+ if translator is not None:
262
+ try:
263
+ calls = translator.translate(text)
264
+ except Exception: # not shell, or not a whole command
265
+ continue
266
+ shape = tuple(c.name for c in calls)
267
+ if shape in shapes and text not in statement.text:
268
+ continue # the code block's 5th docker_compose_run
269
+ shapes.add(shape)
270
+ entry["calls"] = [{"name": c.name, "args": _shown(c.args)} for c in calls]
271
+ entry["needs_spec"] = sorted({c.name for c in calls
272
+ if _no_spec(c.args) and not _has_spec(c.name, translator)})
273
+ out.append(entry)
274
+ return out
275
+
276
+
277
+ def _known(word: str, translator: Any) -> bool:
278
+ registry = getattr(translator, "registry", None)
279
+ return bool(registry is not None and registry.get(word))
280
+
281
+
282
+ def _has_spec(name: str, translator: Any) -> bool:
283
+ cache = getattr(translator, "_agentltl_tool_names", None)
284
+ if cache is None:
285
+ registry = getattr(translator, "registry", None)
286
+ cache = {t["name"] for t in registry.tool_schemas()} if registry is not None else set()
287
+ try:
288
+ translator._agentltl_tool_names = cache
289
+ except AttributeError:
290
+ pass
291
+ return name in cache
292
+
293
+
294
+ def _no_spec(args: Dict[str, Any]) -> bool:
295
+ keys = {k for k in args if k not in ("redirect_to", "overwrite_to", "redirect_from",
296
+ "unknown_paths")}
297
+ return keys == {"argv"}
298
+
299
+
300
+ def _shown(args: Dict[str, Any]) -> Dict[str, Any]:
301
+ return {k: v for k, v in args.items() if v not in (None, False, [], "", {})}
302
+
303
+
304
+ def shown_path(path: str, root: str, home: Optional[str] = None) -> str:
305
+ """*path* relative to the project, or from ``~``: how ``from:`` names a file."""
306
+ path, root = os.path.abspath(path), os.path.abspath(root)
307
+ home = os.path.abspath(home or os.path.expanduser("~"))
308
+ if path.startswith(root + os.sep) and root != home:
309
+ return os.path.relpath(path, root)
310
+ if path.startswith(home + os.sep):
311
+ return "~/" + os.path.relpath(path, home)
312
+ return path
313
+
314
+
315
+ def is_candidate(st: Statement) -> bool:
316
+ """Whether *st* reads like an instruction about tool calls: worth the agent's judgment."""
317
+ if st.file and "/memory/" in st.file and st.context:
318
+ return True # an auto-memory file is one deliberate memory
319
+ text = re.sub(r"[*_`]", "", st.text).strip()
320
+ if not _STRONG.search(text) or not (st.commands or _ACTION.search(text)):
321
+ return False
322
+ return bool(_IMPERATIVE.search(text) or st.emphasis or _ADDRESSED.search(text))
323
+
324
+
325
+ # ── the scan ──────────────────────────────────────────────────────────────────
326
+
327
+ def scan(root: str, ruleset: Any = None, translator: Any = None, user_only: bool = False,
328
+ files: Optional[List[str]] = None, home: Optional[str] = None) -> Dict[str, Any]:
329
+ """Every statement of the memory that applies in *root*, with its hints and status."""
330
+ if files:
331
+ found = [Source(os.path.abspath(f), "auto-memory" if "/memory/" in f else "claude-md",
332
+ PROJECT) for f in files]
333
+ else:
334
+ found = sources(root, user_only, home)
335
+ covered: Dict[str, List[str]] = {}
336
+ for rule in getattr(ruleset, "rules", None) or []:
337
+ for origin in getattr(rule, "origins", ()) or ():
338
+ if origin.get("id"):
339
+ covered.setdefault(origin["id"], []).append(rule.id)
340
+ declined = set(declined_ids(root))
341
+ statements: List[Statement] = []
342
+ for src in found:
343
+ try:
344
+ with open(src.path, encoding="utf-8", errors="replace") as fh:
345
+ text = fh.read()
346
+ except OSError:
347
+ continue
348
+ for st in split(text, shown_path(src.path, root, home), src.kind):
349
+ st.target = src.target
350
+ st.modality = sorted({m.lower() for m in _MODAL.findall(st.text)})
351
+ st.emphasis = bool(_EMPHASIS.search(st.text))
352
+ st.commands = commands_in(st, translator)
353
+ st.candidate = is_candidate(st)
354
+ if st.id in covered:
355
+ st.status, st.rules = "covered", covered[st.id]
356
+ elif st.id in declined:
357
+ st.status = "declined"
358
+ statements.append(st)
359
+ counts = {k: sum(s.status == k for s in statements) for k in ("new", "covered", "declined")}
360
+ counts["candidates"] = sum(s.candidate and s.status == "new" for s in statements)
361
+ counts["statements"] = len(statements)
362
+ return {"sources": [asdict(s) for s in found],
363
+ "statements": [asdict(s) for s in statements],
364
+ "counts": counts}
365
+
366
+
367
+ # ── remembering a "no" ────────────────────────────────────────────────────────
368
+
369
+ def declined_ids(root: str) -> List[str]:
370
+ return list(store.read_project(root).get("memory_declined") or [])
371
+
372
+
373
+ def decline(root: str, ids: List[str]) -> List[str]:
374
+ with store.locked_project(root) as state:
375
+ have = list(state.get("memory_declined") or [])
376
+ have += [i for i in ids if i not in have]
377
+ state["memory_declined"] = have
378
+ return have
379
+
380
+
381
+ def forget(root: str, ids: List[str]) -> List[str]:
382
+ with store.locked_project(root) as state:
383
+ have = [i for i in state.get("memory_declined") or [] if i not in ids]
384
+ state["memory_declined"] = have
385
+ return have
386
+
387
+
388
+ __all__ = ["Source", "Statement", "sources", "files", "nested", "split", "scan", "statement_id",
389
+ "commands_in", "is_candidate", "shown_path", "decline", "forget", "declined_ids",
390
+ "PROJECT", "USER"]