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,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"]
|