agsync 0.1.0__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.
agsync/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """agsync — lint the memory your AI agents read and write."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .engine import Config, Report, check
6
+ from .model import ERROR, OFF, STATUSES, WARN, Finding, Memory
7
+ from .parser import parse
8
+ from .replay import ReplayResult, replay
9
+
10
+ __all__ = [
11
+ "ERROR",
12
+ "OFF",
13
+ "STATUSES",
14
+ "WARN",
15
+ "Config",
16
+ "Finding",
17
+ "Memory",
18
+ "ReplayResult",
19
+ "Report",
20
+ "__version__",
21
+ "check",
22
+ "parse",
23
+ "replay",
24
+ ]
agsync/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
agsync/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+
12
+ from . import __version__
13
+ from .engine import BASELINE_NAME, Config, check, write_baseline
14
+ from .replay import ReplayError, render_text, replay
15
+ from .reporters import FORMATS
16
+ from .rules import all_rules
17
+ from .scaffold import HOOK_DISPATCHER, PRE_COMMIT, scaffold
18
+
19
+ EPILOG = """\
20
+ examples:
21
+ agsync check lint the current repository
22
+ agsync check --format github emit inline annotations in CI
23
+ agsync check --baseline record today's violations and start clean
24
+ agsync replay . how many past pushes the gate would have caught
25
+ agsync init scaffold a memory structure
26
+ agsync install-hooks gate commits locally
27
+ """
28
+
29
+
30
+ def _cmd_check(args) -> int:
31
+ root = os.path.abspath(args.path)
32
+ try:
33
+ config = Config.load(root)
34
+ except ValueError as exc:
35
+ print(f"agsync: invalid config: {exc}", file=sys.stderr)
36
+ return 2
37
+
38
+ if args.baseline:
39
+ report = check(root, config, use_baseline=False)
40
+ path = write_baseline(root, report.findings)
41
+ print(
42
+ f"Wrote {len(report.findings)} existing violation(s) to "
43
+ f"{os.path.relpath(path, root)}.\n"
44
+ f"These are now ignored; new ones will fail. Delete entries as you fix them."
45
+ )
46
+ return 0
47
+
48
+ report = check(root, config, use_baseline=not args.no_baseline)
49
+ FORMATS[args.format](report)
50
+ if args.warn_only:
51
+ return 0
52
+ return 0 if report.ok else 1
53
+
54
+
55
+ def _cmd_replay(args) -> int:
56
+ """Report on history; never gate it.
57
+
58
+ Exit 0 even when every commit would have been rejected — a non-zero exit
59
+ here would mean "replay failed", and scripts have to be able to tell those
60
+ apart.
61
+ """
62
+ tty = sys.stderr.isatty()
63
+ emitted = False
64
+
65
+ def progress(done: int, total: int, sha: str) -> None:
66
+ nonlocal emitted
67
+ if tty:
68
+ emitted = True
69
+ print(f"\r {done}/{total} {sha[:7]}", end="", file=sys.stderr, flush=True)
70
+
71
+ def clear() -> None:
72
+ nonlocal emitted
73
+ if emitted:
74
+ print("\r\033[K", end="", file=sys.stderr, flush=True)
75
+ emitted = False
76
+
77
+ try:
78
+ result = replay(args.repo, args.ref, progress=progress)
79
+ except ReplayError as exc:
80
+ clear()
81
+ print(f"agsync: {exc}", file=sys.stderr)
82
+ return 1
83
+ clear()
84
+
85
+ if args.format == "json":
86
+ json.dump(result.as_dict(), sys.stdout, indent=2)
87
+ sys.stdout.write("\n")
88
+ else:
89
+ render_text(result, show_first_seen=args.first_seen)
90
+ return 0
91
+
92
+
93
+ def _cmd_init(args) -> int:
94
+ root = os.path.abspath(args.path)
95
+ created = scaffold(root, force=args.force)
96
+ if not created:
97
+ print("Nothing to do — memory structure already exists (use --force to overwrite).")
98
+ return 0
99
+ for path in created:
100
+ print(f"created {path}")
101
+ print("\nNext: describe your project's objective in memory/goal.md, then run "
102
+ "`agsync check`.")
103
+ return 0
104
+
105
+
106
+ def _cmd_install_hooks(args) -> int:
107
+ root = os.path.abspath(args.path)
108
+ if not os.path.isdir(os.path.join(root, ".git")):
109
+ print("agsync: not a git repository", file=sys.stderr)
110
+ return 2
111
+
112
+ hooks_dir = os.path.join(root, ".agsync", "hooks")
113
+ os.makedirs(hooks_dir, exist_ok=True)
114
+
115
+ # Preserve whatever hook system is already installed: the dispatcher
116
+ # chains to the previous hooksPath instead of replacing it.
117
+ previous = _git(root, "config", "--get", "core.hooksPath") or ""
118
+ if previous and not previous.startswith(".agsync"):
119
+ chain = previous
120
+ else:
121
+ chain = ".git/hooks"
122
+
123
+ for name, template in (("_dispatch", HOOK_DISPATCHER), ("pre-commit", PRE_COMMIT)):
124
+ path = os.path.join(hooks_dir, name)
125
+ with open(path, "w", encoding="utf-8") as handle:
126
+ handle.write(template.replace("@CHAIN@", chain))
127
+ os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP)
128
+ print(f"created {os.path.relpath(path, root)}")
129
+
130
+ _git(root, "config", "core.hooksPath", ".agsync/hooks")
131
+ print("set core.hooksPath = .agsync/hooks")
132
+ print(f"chaining to {chain}")
133
+ print("\nUndo with: git config --unset core.hooksPath")
134
+ return 0
135
+
136
+
137
+ def _cmd_rules(args) -> int:
138
+ width = max(len(rule.name) for rule in all_rules())
139
+ for rule in all_rules():
140
+ print(f"{rule.name:<{width}} {rule.default_severity:<5} {rule.description}")
141
+ return 0
142
+
143
+
144
+ def _git(root: str, *args: str) -> str:
145
+ try:
146
+ out = subprocess.run(
147
+ ["git", *args], cwd=root, capture_output=True, text=True, check=False
148
+ )
149
+ return out.stdout.strip()
150
+ except FileNotFoundError:
151
+ return ""
152
+
153
+
154
+ def build_parser() -> argparse.ArgumentParser:
155
+ parser = argparse.ArgumentParser(
156
+ prog="agsync",
157
+ description="Lint the memory your AI agents read and write.",
158
+ epilog=EPILOG,
159
+ formatter_class=argparse.RawDescriptionHelpFormatter,
160
+ )
161
+ parser.add_argument("--version", action="version", version=f"agsync {__version__}")
162
+ subparsers = parser.add_subparsers(dest="command")
163
+
164
+ check_parser = subparsers.add_parser("check", help="report integrity violations")
165
+ check_parser.add_argument("path", nargs="?", default=".")
166
+ check_parser.add_argument("--format", choices=sorted(FORMATS), default="text")
167
+ check_parser.add_argument("--warn-only", action="store_true",
168
+ help="always exit 0 (use on first run)")
169
+ check_parser.add_argument("--baseline", action="store_true",
170
+ help=f"record current violations into {BASELINE_NAME}")
171
+ check_parser.add_argument("--no-baseline", action="store_true",
172
+ help="ignore the baseline file and report everything")
173
+ check_parser.set_defaults(fn=_cmd_check)
174
+
175
+ replay_parser = subparsers.add_parser(
176
+ "replay", help="replay history and count the pushes that would have been rejected"
177
+ )
178
+ replay_parser.add_argument("repo", help="path or clone URL of the repository to replay")
179
+ replay_parser.add_argument("--ref", default="main",
180
+ help="branch, remote branch or tag to walk (default: main)")
181
+ replay_parser.add_argument("--first-seen", action="store_true",
182
+ help="also show when each rule first started failing")
183
+ replay_parser.add_argument("--format", choices=("text", "json"), default="text")
184
+ replay_parser.set_defaults(fn=_cmd_replay)
185
+
186
+ init_parser = subparsers.add_parser("init", help="scaffold a memory structure")
187
+ init_parser.add_argument("path", nargs="?", default=".")
188
+ init_parser.add_argument("--force", action="store_true")
189
+ init_parser.set_defaults(fn=_cmd_init)
190
+
191
+ hooks_parser = subparsers.add_parser("install-hooks", help="gate commits locally")
192
+ hooks_parser.add_argument("path", nargs="?", default=".")
193
+ hooks_parser.set_defaults(fn=_cmd_install_hooks)
194
+
195
+ rules_parser = subparsers.add_parser("rules", help="list every rule")
196
+ rules_parser.set_defaults(fn=_cmd_rules)
197
+
198
+ return parser
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> int:
202
+ parser = build_parser()
203
+ args = parser.parse_args(argv)
204
+ if not getattr(args, "fn", None):
205
+ parser.print_help()
206
+ return 0
207
+ return args.fn(args)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())
agsync/engine.py ADDED
@@ -0,0 +1,147 @@
1
+ """Configuration, baseline filtering, and the check engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tomllib
8
+ from dataclasses import dataclass, field, replace
9
+
10
+ from .model import ERROR, OFF, WARN, Finding, Memory
11
+ from .parser import parse
12
+ from .rules import all_rules
13
+
14
+ CONFIG_NAMES = (".agsync.toml", "agsync.toml")
15
+ BASELINE_NAME = ".agsync-baseline.json"
16
+ VALID_SEVERITIES = (ERROR, WARN, OFF)
17
+
18
+
19
+ @dataclass
20
+ class Config:
21
+ """Severity per rule, plus paths to ignore.
22
+
23
+ Severity is configuration; rule logic is not. This split is what allows a
24
+ repo with existing violations to adopt the tool gradually — turn everything
25
+ to ``warn``, then promote rules to ``error`` one at a time.
26
+ """
27
+
28
+ severities: dict[str, str] = field(default_factory=dict)
29
+ exclude: list[str] = field(default_factory=list)
30
+
31
+ @classmethod
32
+ def load(cls, root: str) -> Config:
33
+ for name in CONFIG_NAMES:
34
+ path = os.path.join(root, name)
35
+ if os.path.isfile(path):
36
+ with open(path, "rb") as handle:
37
+ data = tomllib.load(handle)
38
+ return cls.from_dict(data)
39
+ return cls()
40
+
41
+ @classmethod
42
+ def from_dict(cls, data: dict) -> Config:
43
+ section = data.get("tool", {}).get("agsync", data)
44
+ _validate_config_keys(section)
45
+ severities = {}
46
+ for name, value in (section.get("rules") or {}).items():
47
+ value = str(value).lower()
48
+ if value not in VALID_SEVERITIES:
49
+ raise ValueError(
50
+ f"invalid severity {value!r} for rule {name!r}; "
51
+ f"expected one of {', '.join(VALID_SEVERITIES)}"
52
+ )
53
+ severities[name] = value
54
+ return cls(severities=severities, exclude=list(section.get("exclude") or []))
55
+
56
+ def severity_for(self, rule_name: str, default: str) -> str:
57
+ return self.severities.get(rule_name, default)
58
+
59
+
60
+ @dataclass
61
+ class Report:
62
+ findings: list[Finding]
63
+ memory: Memory
64
+ suppressed: int = 0
65
+
66
+ @property
67
+ def errors(self) -> list[Finding]:
68
+ return [f for f in self.findings if f.severity == ERROR]
69
+
70
+ @property
71
+ def warnings(self) -> list[Finding]:
72
+ return [f for f in self.findings if f.severity == WARN]
73
+
74
+ @property
75
+ def ok(self) -> bool:
76
+ return not self.errors
77
+
78
+
79
+ def load_baseline(root: str) -> set:
80
+ path = os.path.join(root, BASELINE_NAME)
81
+ if not os.path.isfile(path):
82
+ return set()
83
+ with open(path, encoding="utf-8") as handle:
84
+ data = json.load(handle)
85
+ return set(data.get("fingerprints", []))
86
+
87
+
88
+ def write_baseline(root: str, findings: list[Finding]) -> str:
89
+ path = os.path.join(root, BASELINE_NAME)
90
+ payload = {
91
+ "version": 1,
92
+ "note": "Pre-existing violations, ignored by `agsync check`. "
93
+ "Delete entries as you fix them; never add by hand.",
94
+ "fingerprints": sorted({f.fingerprint() for f in findings}),
95
+ }
96
+ with open(path, "w", encoding="utf-8") as handle:
97
+ json.dump(payload, handle, indent=2)
98
+ handle.write("\n")
99
+ return path
100
+
101
+
102
+ def _excluded(path: str, patterns: list[str]) -> bool:
103
+ normalized = path.replace(os.sep, "/")
104
+ return any(
105
+ normalized == pattern or normalized.startswith(pattern.rstrip("/") + "/")
106
+ for pattern in patterns
107
+ )
108
+
109
+
110
+ def check(root: str, config: Config | None = None, use_baseline: bool = True) -> Report:
111
+ """Parse ``root`` and run every enabled rule over it."""
112
+ config = config or Config.load(root)
113
+ memory = parse(root)
114
+ baseline = load_baseline(root) if use_baseline else set()
115
+
116
+ findings: list[Finding] = []
117
+ suppressed = 0
118
+ for rule in all_rules():
119
+ severity = config.severity_for(rule.name, rule.default_severity)
120
+ if severity == OFF:
121
+ continue
122
+ for finding in rule.fn(memory):
123
+ if _excluded(finding.path, config.exclude):
124
+ continue
125
+ if finding.fingerprint() in baseline:
126
+ suppressed += 1
127
+ continue
128
+ # Config overrides the severity the rule declared for itself.
129
+ # `replace` rather than a rebuild: a new field added to Finding
130
+ # must not be silently dropped here, which would change the
131
+ # fingerprint of every re-severitied finding.
132
+ findings.append(
133
+ finding if finding.severity == severity
134
+ else replace(finding, severity=severity)
135
+ )
136
+
137
+ findings.sort(key=lambda f: (f.path, f.line, f.rule))
138
+ return Report(findings=findings, memory=memory, suppressed=suppressed)
139
+
140
+
141
+ def _validate_config_keys(section: dict) -> None:
142
+ """Guard against the TOML footgun where a top-level key lands inside [rules]."""
143
+ unknown = set(section) - {"rules", "exclude"}
144
+ if unknown:
145
+ raise ValueError(
146
+ f"unknown config key(s): {', '.join(sorted(unknown))}"
147
+ )
agsync/model.py ADDED
@@ -0,0 +1,138 @@
1
+ """Normalized data model.
2
+
3
+ Rules operate exclusively on these objects, never on raw markdown. Keeping the
4
+ parser and the rules separated by a stable structure is what lets the parser
5
+ absorb messy real-world formats without every rule learning about them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import dataclass, field
12
+
13
+ #: The only task statuses that carry machine-readable meaning.
14
+ STATUSES = ("todo", "in-progress", "done", "superseded")
15
+
16
+ ERROR = "error"
17
+ WARN = "warn"
18
+ OFF = "off"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Finding:
23
+ """A single rule violation.
24
+
25
+ The report contract is deliberately fixed and minimal: every consumer
26
+ (text output, JSON, GitHub annotations, editors) derives from these five
27
+ fields alone.
28
+ """
29
+
30
+ rule: str
31
+ path: str
32
+ line: int
33
+ message: str
34
+ severity: str = ERROR
35
+ #: What the finding is *about* — a decision ID, a task number, a link
36
+ #: target. Stable identity for the baseline; see :meth:`fingerprint`.
37
+ subject: str = ""
38
+
39
+ def fingerprint(self) -> str:
40
+ """Stable identity for the baseline file.
41
+
42
+ Hashes structured fields, never the rendered message. Messages are
43
+ prose written for humans: they embed line numbers, counts and
44
+ truncated quotes, all of which change when the surrounding file is
45
+ edited even though the violation has not. Hashing them meant a
46
+ baselined finding could resurrect itself after an unrelated edit, which
47
+ is the exact failure that excluding ``line`` was meant to prevent.
48
+
49
+ ``subject`` falls back to ``message`` so that a rule which does not
50
+ supply one still gets a usable fingerprint rather than colliding with
51
+ every other finding of its rule in the same file.
52
+ """
53
+ raw = f"{self.rule}\x00{self.path}\x00{self.subject or self.message}"
54
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
55
+
56
+ def as_dict(self) -> dict:
57
+ return {
58
+ "rule": self.rule,
59
+ "path": self.path,
60
+ "line": self.line,
61
+ "message": self.message,
62
+ "severity": self.severity,
63
+ "subject": self.subject,
64
+ "fingerprint": self.fingerprint(),
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class Decision:
70
+ """One entry in the decision ledger."""
71
+
72
+ id: str
73
+ title: str
74
+ path: str
75
+ line: int
76
+ #: field name (lowercased) -> (folded value, line number)
77
+ fields: dict[str, tuple[str, int]] = field(default_factory=dict)
78
+ supersedes: list[str] = field(default_factory=list)
79
+ amends: list[str] = field(default_factory=list)
80
+
81
+
82
+ @dataclass
83
+ class Task:
84
+ """One task file.
85
+
86
+ ``status_base`` is the enum candidate; ``status_qualifier`` is whatever
87
+ free text trailed it. Splitting them is what lets an existing repo become
88
+ parseable without rewriting every file at once.
89
+ """
90
+
91
+ num: str
92
+ path: str
93
+ line: int = 1
94
+ title: str = ""
95
+ status_raw: str = ""
96
+ status_base: str = ""
97
+ status_qualifier: str = ""
98
+ status_line: int = 1
99
+ depends_on: list[str] = field(default_factory=list)
100
+ blocks: list[str] = field(default_factory=list)
101
+ decisions: list[str] = field(default_factory=list)
102
+
103
+
104
+ @dataclass
105
+ class IndexRow:
106
+ """One row of a task table in the index file."""
107
+
108
+ num: str
109
+ target: str
110
+ status_raw: str
111
+ status_base: str
112
+ line: int
113
+
114
+
115
+ @dataclass
116
+ class Memory:
117
+ """The parsed memory graph handed to every rule."""
118
+
119
+ root: str
120
+ decisions: dict[str, Decision] = field(default_factory=dict)
121
+ #: (redefinition, first definition) pairs — duplicates never enter ``decisions``
122
+ duplicate_decisions: list[tuple[Decision, Decision]] = field(default_factory=list)
123
+ tasks: dict[str, Task] = field(default_factory=dict)
124
+ index: list[IndexRow] = field(default_factory=list)
125
+ index_path: str = "tasks/README.md"
126
+ protocol_path: str = "AGENTS.md"
127
+ #: every markdown file in the repo, repo-relative
128
+ markdown: list[str] = field(default_factory=list)
129
+ #: the subset that is actually agent memory — the protocol file and
130
+ #: anything under memory/ or tasks/. A ``D-021`` in a README is prose;
131
+ #: the same token inside the memory surface is a reference that must
132
+ #: resolve. Rules that scan text use this, never ``markdown``.
133
+ surface: list[str] = field(default_factory=list)
134
+ #: path -> lines, populated lazily by the parser and reused by rules
135
+ sources: dict[str, list[str]] = field(default_factory=dict)
136
+
137
+ def lines(self, path: str) -> list[str]:
138
+ return self.sources.get(path, [])