lrdmem 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.
lrdmem/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lrdmem: shared git-backed memory store for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
lrdmem/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from lrdmem.cli import main
4
+
5
+ sys.exit(main())
lrdmem/checks.py ADDED
@@ -0,0 +1,156 @@
1
+ """Obligation checks: run `check.run` commands, but only the exact text a human approved on this device."""
2
+
3
+ import hashlib
4
+ import math
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from lrdmem.store import Memory, Store
13
+ from lrdmem.triggers import matches
14
+
15
+ TIMEOUT = 300
16
+ TAIL = 60
17
+ KILL_WAIT = 5 # seconds to wait for output pipes after killing the group
18
+
19
+
20
+ @dataclass
21
+ class CheckResult:
22
+ """Outcome of one run_checks call: failures are (slug, output tail), unrunnable are (slug, reason)."""
23
+
24
+ failures: list[tuple[str, str]] = field(default_factory=list)
25
+ skipped_pending: list[str] = field(default_factory=list)
26
+ ran: list[str] = field(default_factory=list)
27
+ unrunnable: list[tuple[str, str]] = field(default_factory=list)
28
+ outcomes: list[tuple[Memory, bool, str, list[str]]] = field(default_factory=list) # (memory, passed, tail, files)
29
+
30
+
31
+ def key(store: Store, memory: Memory) -> str:
32
+ """Allowlist key: the store-relative path of the memory without .md."""
33
+ return memory.path.relative_to(store.root).with_suffix("").as_posix()
34
+
35
+
36
+ def command_text(memory: Memory) -> str | None:
37
+ """The check.run text of an obligation, or None when it has none."""
38
+ c = memory.check
39
+ run = c.get("run") if c else None
40
+ return run if memory.kind == "obligation" and isinstance(run, str) and run.strip() else None
41
+
42
+
43
+ def command_hash(memory: Memory) -> str:
44
+ """sha256 hex of the memory's check.run exactly as written."""
45
+ return hashlib.sha256((command_text(memory) or "").encode("utf-8")).hexdigest()
46
+
47
+
48
+ def _script(cmd: str) -> str | None:
49
+ """cmd with each `{files}` word replaced by `"$@"`, or None unless every `{files}` is a bare unquoted word."""
50
+ pieces: list[str] = []
51
+ tok, clean, quote, i = "", True, "", 0 # clean: the current word holds no quote or escape
52
+
53
+ def end_word() -> None:
54
+ nonlocal tok, clean
55
+ pieces.append('"$@"' if clean and tok == "{files}" else tok)
56
+ tok, clean = "", True
57
+
58
+ while i < len(cmd):
59
+ ch = cmd[i]
60
+ if ch == "\\" and quote != "'": # escape: keep it and the next char together
61
+ tok, clean, i = tok + cmd[i:i + 2], False, i + 1
62
+ elif quote:
63
+ tok += ch
64
+ quote = "" if ch == quote else quote
65
+ elif ch in "'\"":
66
+ tok, clean, quote = tok + ch, False, ch
67
+ elif ch.isspace():
68
+ end_word()
69
+ pieces.append(ch)
70
+ else:
71
+ tok += ch
72
+ i += 1
73
+ end_word()
74
+ script = "".join(pieces)
75
+ # an occurrence that did not become a bare word was quoted, escaped or glued to other text
76
+ return script if script.count('"$@"') - cmd.count('"$@"') == cmd.count("{files}") else None
77
+
78
+
79
+ def problem(memory: Memory) -> str | None:
80
+ """Why this memory's check.run can neither be approved nor run, or None when it is fine."""
81
+ cmd = command_text(memory)
82
+ if cmd is None:
83
+ return "no check.run"
84
+ if not all(0x20 <= ord(c) <= 0x7E for c in cmd):
85
+ return "unapprovable: non-printable or non-ASCII characters"
86
+ if "{files}" in cmd and any(t in cmd for t in ("$(", "`", "${")):
87
+ return "unrunnable: {files} cannot be combined with $(, ` or ${"
88
+ if _script(cmd) is None:
89
+ return "unrunnable: {files} must be an unquoted word"
90
+ return None
91
+
92
+
93
+ def _approved(store: Store, memory: Memory, cfg: dict) -> bool:
94
+ return cfg["allow"].get(key(store, memory)) == command_hash(memory)
95
+
96
+
97
+ def pending(store: Store, cfg: dict) -> list[Memory]:
98
+ """Obligations with a check.run whose hash is not the approved one."""
99
+ return [m for m in store.memories() if command_text(m) and not _approved(store, m, cfg)]
100
+
101
+
102
+ def _kill(proc: subprocess.Popen) -> None:
103
+ try:
104
+ os.killpg(proc.pid, signal.SIGKILL)
105
+ except OSError:
106
+ proc.kill()
107
+ try:
108
+ proc.communicate(timeout=KILL_WAIT)
109
+ except subprocess.TimeoutExpired: # a grandchild in its own session still holds the pipe
110
+ proc.stdout.close()
111
+ proc.wait() # the shell itself got SIGKILL: reap it
112
+
113
+
114
+ def run_checks(store: Store, env: str, repo: str, repo_root: Path, on: str, files: list[str], cfg: dict,
115
+ budget: float | None = None) -> CheckResult:
116
+ """Run the approved obligations for `on` against the files their triggers match; unapproved ones are never run.
117
+
118
+ budget is the wall-clock seconds shared by every check of this call (default TIMEOUT); each check gets what
119
+ is left of it. File names are passed as argv to `sh -c`, never spliced into the command text.
120
+ """
121
+ result = CheckResult()
122
+ deadline = time.monotonic() + (TIMEOUT if budget is None else budget)
123
+ for m in store.memories():
124
+ if not command_text(m) or m.check.get("on") != on or m.state != "active":
125
+ continue # only active obligations: candidates and contested ones are never enforced
126
+ hit = [f for f in files if matches(m, env, repo, path=f, env_wide=True)]
127
+ if not hit:
128
+ continue
129
+ why = problem(m)
130
+ if why:
131
+ result.unrunnable.append((m.slug, why))
132
+ continue
133
+ if not _approved(store, m, cfg):
134
+ result.skipped_pending.append(m.slug)
135
+ continue
136
+ left = deadline - time.monotonic()
137
+ if left <= 0:
138
+ result.failures.append((m.slug, "not run: the check time budget is used up"))
139
+ continue
140
+ argv = ["sh", "-c", _script(command_text(m)), "sh", *("./" + f if f.startswith("-") else f for f in hit)]
141
+ proc = subprocess.Popen(argv, cwd=repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
142
+ errors="replace", start_new_session=True)
143
+ try:
144
+ out, _ = proc.communicate(timeout=left)
145
+ except subprocess.TimeoutExpired:
146
+ _kill(proc)
147
+ result.failures.append((m.slug, f"timed out after {math.ceil(left)}s"))
148
+ result.outcomes.append((m, False, f"timed out after {math.ceil(left)}s", hit))
149
+ continue
150
+ tail = "\n".join(out.splitlines()[-TAIL:])
151
+ if proc.returncode:
152
+ result.failures.append((m.slug, tail))
153
+ else:
154
+ result.ran.append(m.slug)
155
+ result.outcomes.append((m, not proc.returncode, tail, hit))
156
+ return result