buddie 1.0.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.
- buddie/__init__.py +3 -0
- buddie/__main__.py +5 -0
- buddie/anchors.py +216 -0
- buddie/cli.py +215 -0
- buddie/config.py +193 -0
- buddie/context.py +124 -0
- buddie/decisions.py +260 -0
- buddie/effects.py +292 -0
- buddie/hook.py +477 -0
- buddie/journal.py +280 -0
- buddie/mandate.py +499 -0
- buddie/mcp.py +214 -0
- buddie/promises.py +519 -0
- buddie/remote.py +293 -0
- buddie/stores.py +130 -0
- buddie/suggest.py +174 -0
- buddie/summary.py +490 -0
- buddie/verbatim/__init__.py +3 -0
- buddie/verbatim/__main__.py +3 -0
- buddie/verbatim/access.py +339 -0
- buddie/verbatim/check.py +575 -0
- buddie/verbatim/cli.py +228 -0
- buddie/verbatim/numbers.py +539 -0
- buddie/verbatim/report.py +329 -0
- buddie/verbatim/store.py +137 -0
- buddie/verbatim/textlayer.py +174 -0
- buddie/verify.py +213 -0
- buddie-1.0.0.dist-info/METADATA +357 -0
- buddie-1.0.0.dist-info/RECORD +34 -0
- buddie-1.0.0.dist-info/WHEEL +5 -0
- buddie-1.0.0.dist-info/entry_points.txt +3 -0
- buddie-1.0.0.dist-info/licenses/LICENSE +177 -0
- buddie-1.0.0.dist-info/licenses/NOTICE +21 -0
- buddie-1.0.0.dist-info/top_level.txt +1 -0
buddie/__init__.py
ADDED
buddie/__main__.py
ADDED
buddie/anchors.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""State claims in a report: `⚓ path#sha256`, `⚓ path@commit`, `⚓ fact=value` (lab README, rule 7).
|
|
2
|
+
|
|
3
|
+
This is the repo-agnostic half of lab's tools/anchors.py: the report is a message from another session, so the
|
|
4
|
+
repository it talks about is not known in advance. Each anchor is resolved against the given repos in order:
|
|
5
|
+
|
|
6
|
+
path#sha HOLDS when some repo has the file and its sha256 starts with the prefix; BROKEN when a repo has
|
|
7
|
+
the file with other bytes; UNCHECKABLE when no repo has that path
|
|
8
|
+
path@commit HOLDS when some repo has the commit and the file in it; BROKEN when the commit is there without
|
|
9
|
+
the file; UNCHECKABLE when no repo has the commit (shallow clone, another repository)
|
|
10
|
+
fact=value computed by FACTS of the first repo with tools/anchors.py (lab); UNCHECKABLE when none has it
|
|
11
|
+
|
|
12
|
+
pr:, ci: and run: anchors (a PR's state, checks on a commit, a command and its output) are judged by effects.py
|
|
13
|
+
against GitHub and the session transcript given in ctx; choice:<id>=<step> (what the person picked on a summary card)
|
|
14
|
+
by summary.py against the hook's journal.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import importlib.util
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# the sign inside the backticks (`⚓ x`) or right before them (⚓ `x`, as people and the buddie skill write it)
|
|
25
|
+
BACKTICKED = re.compile(r"(?:`⚓\s*|(?:(?<!`)⚓\s*|⚓\s+)`\s*)([^`\s][^`]*?)\s*`")
|
|
26
|
+
FILE_SHA = re.compile(r"^(?P<path>[^#@=\s]+)#(?P<sha>[0-9a-f]{12,64})$")
|
|
27
|
+
FILE_AT = re.compile(r"^(?P<path>[^#@=\s]+)@(?P<commit>[0-9a-f]{7,40})$")
|
|
28
|
+
FACT = re.compile(r"^(?P<name>[a-z0-9_.]+)=(?P<value>\S+)$")
|
|
29
|
+
CODE_SPAN = re.compile(r"`[^`]*`")
|
|
30
|
+
BARE_SIGN = re.compile(r"⚓[ \t]*(?=[^\s`⚓])")
|
|
31
|
+
# a placeholder from the anchor table, not a claim: «(⚓ path@commit, … or ⚓ run:CMD => OUTPUT)» quoted from a return
|
|
32
|
+
TEMPLATE = re.compile(r"<[^>]*>|…|\b(?:OWNER/REPO|SHA)\b|^(?:path#sha256|path@commit|fact=value)$")
|
|
33
|
+
RUN_TEMPLATE = re.compile(r"^run:\s*(?:CMD|<[^>]*>)(?:\s|$)|=>\s*(?:OUT|OUTPUT|<[^>]*>)$")
|
|
34
|
+
TRAIL = ".,;:!?»\"'"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bare_end(line: str, start: int) -> int:
|
|
38
|
+
"""Where a bare anchor's run: claim ends: the next sign, a table cell's border (a line that starts with |),
|
|
39
|
+
or the end of the line."""
|
|
40
|
+
end = len(line)
|
|
41
|
+
if (nxt := line.find("⚓", start)) != -1:
|
|
42
|
+
end = nxt
|
|
43
|
+
if line.lstrip().startswith("|") and (bar := line.find("|", start)) != -1:
|
|
44
|
+
end = min(end, bar)
|
|
45
|
+
return end
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bare(line: str, start: int) -> tuple[str, int] | None:
|
|
49
|
+
"""The bare anchor (no backticks, E017 / NEXT №97) that starts at `start` and where it ends; None when the text
|
|
50
|
+
after the sign is not an anchor (prose, a placeholder): a bare sign is a claim only in an anchor's own form."""
|
|
51
|
+
from buddie import effects
|
|
52
|
+
rest = line[start:_bare_end(line, start)]
|
|
53
|
+
if rest.startswith("run:"): # CMD => OUT holds spaces: to the end of the line or the delimiter
|
|
54
|
+
cand = rest.rstrip()
|
|
55
|
+
cand = cand.rstrip(TRAIL + " ")
|
|
56
|
+
if cand.endswith(")") and cand.count(")") > cand.count("("):
|
|
57
|
+
cand = cand[:-1].rstrip()
|
|
58
|
+
end = start + len(cand)
|
|
59
|
+
else:
|
|
60
|
+
token = rest.split()[0] if rest.split() else ""
|
|
61
|
+
cand = token.rstrip(TRAIL)
|
|
62
|
+
if cand.endswith(")") and "(" not in cand:
|
|
63
|
+
cand = cand.rstrip(")" + TRAIL)
|
|
64
|
+
end = start + len(cand)
|
|
65
|
+
if not cand or (RUN_TEMPLATE.search(cand) if cand.startswith("run:") else TEMPLATE.search(cand)):
|
|
66
|
+
return None
|
|
67
|
+
ok = (FILE_SHA.match(cand) or FILE_AT.match(cand) or FACT.match(cand) or cand.startswith("choice:")
|
|
68
|
+
or effects.PR.match(cand) or effects.CI.match(cand) or effects.RUN.match(cand))
|
|
69
|
+
return (cand, end) if ok else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def spans(line: str) -> list[tuple[int, int, str]]:
|
|
73
|
+
"""(start, end, anchor) of each anchor on a line: in backticks, or bare after the sign up to a space (a file,
|
|
74
|
+
a commit, a fact, a PR) or, for run:, to the end of the line or the delimiter (NEXT №97)."""
|
|
75
|
+
found = [(m.start(), m.end(), m.group(1)) for m in BACKTICKED.finditer(line)]
|
|
76
|
+
masked = list(line)
|
|
77
|
+
for a, b, _ in found:
|
|
78
|
+
masked[a:b] = " " * (b - a)
|
|
79
|
+
masked = CODE_SPAN.sub(lambda m: " " * len(m.group()), "".join(masked))
|
|
80
|
+
for m in BARE_SIGN.finditer(masked):
|
|
81
|
+
if got := _bare(masked, m.end()):
|
|
82
|
+
found.append((m.start(), got[1], got[0]))
|
|
83
|
+
return sorted(found)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Anchors:
|
|
87
|
+
"""The anchors of a text, as the regex it replaces was used: findall, search, sub, line by line."""
|
|
88
|
+
|
|
89
|
+
def findall(self, text: str) -> list[str]:
|
|
90
|
+
return [a for line in text.split("\n") for _, _, a in spans(line)]
|
|
91
|
+
|
|
92
|
+
def search(self, text: str) -> bool:
|
|
93
|
+
return any(spans(line) for line in text.split("\n"))
|
|
94
|
+
|
|
95
|
+
def sub(self, repl: str, text: str) -> str:
|
|
96
|
+
out = []
|
|
97
|
+
for line in text.split("\n"):
|
|
98
|
+
for a, b, _ in reversed(spans(line)):
|
|
99
|
+
line = line[:a] + repl + line[b:]
|
|
100
|
+
out.append(line)
|
|
101
|
+
return "\n".join(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
ANCHOR = _Anchors()
|
|
105
|
+
|
|
106
|
+
HOLDS, BROKEN, UNCHECKABLE = "HOLDS", "BROKEN", "UNCHECKABLE"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _git(repo: Path, *args: str) -> int:
|
|
110
|
+
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True).returncode
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _facts(repo: Path):
|
|
114
|
+
"""tools/anchors.py of a repo that has one (lab), loaded once; None otherwise."""
|
|
115
|
+
path = repo / "tools" / "anchors.py"
|
|
116
|
+
if not path.is_file():
|
|
117
|
+
return None
|
|
118
|
+
spec = importlib.util.spec_from_file_location(f"buddie_facts_{abs(hash(str(repo)))}", path)
|
|
119
|
+
module = importlib.util.module_from_spec(spec)
|
|
120
|
+
spec.loader.exec_module(module)
|
|
121
|
+
return module
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_one(anchor: str, repos: list[Path], cache: dict, ctx: dict | None = None) -> dict:
|
|
125
|
+
from buddie import effects
|
|
126
|
+
if effects.is_effect(anchor):
|
|
127
|
+
return effects.check(anchor, ctx)
|
|
128
|
+
if anchor.startswith("choice:"):
|
|
129
|
+
from buddie.summary import check_choice
|
|
130
|
+
return check_choice(anchor, ctx)
|
|
131
|
+
out = {"anchor": anchor}
|
|
132
|
+
if m := FILE_SHA.match(anchor):
|
|
133
|
+
for repo in repos:
|
|
134
|
+
target = repo / m["path"]
|
|
135
|
+
if target.is_file():
|
|
136
|
+
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
137
|
+
if actual.startswith(m["sha"]):
|
|
138
|
+
return dict(out, status=HOLDS, repo=str(repo))
|
|
139
|
+
out.update(status=BROKEN, repo=str(repo), why=f"sha256 is now {actual[:12]}")
|
|
140
|
+
return out if "status" in out else dict(out, status=UNCHECKABLE, why=f"no repo has {m['path']}")
|
|
141
|
+
if m := FILE_AT.match(anchor):
|
|
142
|
+
for repo in repos:
|
|
143
|
+
if _git(repo, "cat-file", "-e", f"{m['commit']}^{{commit}}") != 0:
|
|
144
|
+
continue
|
|
145
|
+
if _git(repo, "cat-file", "-e", f"{m['commit']}:{m['path']}") == 0:
|
|
146
|
+
return dict(out, status=HOLDS, repo=str(repo))
|
|
147
|
+
return dict(out, status=BROKEN, repo=str(repo), why=f"{m['commit']} has no {m['path']}")
|
|
148
|
+
return dict(out, status=UNCHECKABLE, why=f"no repo has commit {m['commit']} (fetch it, or full history)")
|
|
149
|
+
if m := FACT.match(anchor):
|
|
150
|
+
for repo in repos:
|
|
151
|
+
key = ("facts", str(repo))
|
|
152
|
+
if key not in cache:
|
|
153
|
+
cache[key] = _facts(repo)
|
|
154
|
+
module = cache[key]
|
|
155
|
+
if module is None:
|
|
156
|
+
continue
|
|
157
|
+
if m["name"] not in module.FACTS:
|
|
158
|
+
return dict(out, status=BROKEN, repo=str(repo), why=f"unknown fact {m['name']}")
|
|
159
|
+
vkey = ("value", str(repo), m["name"])
|
|
160
|
+
if vkey not in cache:
|
|
161
|
+
cache[vkey] = str(module.fact(m["name"]))
|
|
162
|
+
if cache[vkey] == m["value"]:
|
|
163
|
+
return dict(out, status=HOLDS, repo=str(repo))
|
|
164
|
+
return dict(out, status=BROKEN, repo=str(repo), why=f"is now {cache[vkey]}")
|
|
165
|
+
return dict(out, status=UNCHECKABLE, why="no repo defines facts (tools/anchors.py)")
|
|
166
|
+
return dict(out, status=BROKEN, why="not an anchor: expected path#sha256, path@commit, fact=value, pr:, ci:, run: or choice:")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_text(text: str, repos: list[Path], ctx: dict | None = None) -> list[dict]:
|
|
170
|
+
"""ctx for effect anchors: {"github": a GET function or None (offline), "transcript": the session jsonl}."""
|
|
171
|
+
results, cache, ctx = [], {}, ctx if ctx is not None else {}
|
|
172
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
173
|
+
for anchor in ANCHOR.findall(line):
|
|
174
|
+
results.append(dict(check_one(anchor, repos, cache, ctx), line=number))
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# a number stated as a fact ("73 из 84", "11 %", "505 цитат", "0,61"); ids, dates, times, links and code are not
|
|
179
|
+
COUNT = re.compile(r"(?<![\w#№.,:/-])\d+(?:[.,]\d+)?(?![\w.:/-]|,\d)")
|
|
180
|
+
# a lone year names a time, as a date does (NEXT №108: «по переписи 2020 года», «за 2024 год», «April 2026»,
|
|
181
|
+
# «(2021)»): 1800-2099, or a range of two, followed by a year word, or after a month (with its day: «14 марта 2024»),
|
|
182
|
+
# after in/since/until, or in brackets
|
|
183
|
+
_Y = r"(?<![\w#№.,:/-])(?:1[89]|20)\d{2}(?![\w,:/-]|\.\d)"
|
|
184
|
+
_MONTH = (r"(?i:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?"
|
|
185
|
+
r"|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|январ[а-я]*|феврал[а-я]*|март[а-я]*|апрел[а-я]*|ма[йяе]|июн[а-я]*"
|
|
186
|
+
r"|июл[а-я]*|август[а-я]*|сентябр[а-я]*|октябр[а-я]*|ноябр[а-я]*|декабр[а-я]*)")
|
|
187
|
+
YEAR = re.compile(rf"(?:{_Y}\s*[–—]\s*)?{_Y}(?=\s*(?:-(?:го|м|й|х)\b|гг?\.|(?i:год[а-я]*|year)\b))"
|
|
188
|
+
rf"|(?:\b\d{{1,2}}\s+)?\b{_MONTH}\s+{_Y}|\b(?i:in|since|until)\s+{_Y}|\(\s*{_Y}\s*\)")
|
|
189
|
+
NOT_COUNT = re.compile(r"`[^`]*`|\[[^\]]*\]\([^)]*\)|https?://\S+|\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}:\d{2}\b|" + YEAR.pattern)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def counts(line: str) -> list[str]:
|
|
193
|
+
"""The counts and shares a line states, leaving out ids, dates, lone years, times, links and code."""
|
|
194
|
+
return COUNT.findall(NOT_COUNT.sub(" ", line))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def unanchored_lines(text: str, backed=()) -> list[int]:
|
|
198
|
+
"""Numbers of the lines that state a count with no anchor on the line; `backed` lines (a quote found on the page
|
|
199
|
+
the line links, verify.quote_backed) carry their source and are left out."""
|
|
200
|
+
return [n for n, line in enumerate(text.splitlines(), 1)
|
|
201
|
+
if n not in backed and not ANCHOR.search(line) and counts(line)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def unanchored(text: str, limit: int = 5, backed=()) -> tuple[int, list[str]]:
|
|
205
|
+
"""Lines that state a count or a share with no anchor on the line (E003: bare numbers drift when relayed).
|
|
206
|
+
A signal for the reader, never a failure: chat replies are not bound by the anchor rule."""
|
|
207
|
+
found, lines = unanchored_lines(text, backed), text.splitlines()
|
|
208
|
+
return len(found), [f"line {n}: {lines[n - 1].strip()[:120]}" for n in found[:limit]]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def find_repos(cwd: Path) -> list[Path]:
|
|
212
|
+
"""The git repo around cwd, else the git repos directly under it (a cloud session clones each repo there)."""
|
|
213
|
+
top = subprocess.run(["git", "-C", str(cwd), "rev-parse", "--show-toplevel"], capture_output=True, text=True)
|
|
214
|
+
if top.returncode == 0:
|
|
215
|
+
return [Path(top.stdout.strip())]
|
|
216
|
+
return sorted(p for p in cwd.iterdir() if (p / ".git").exists()) if cwd.is_dir() else []
|
buddie/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""buddie command line.
|
|
2
|
+
|
|
3
|
+
buddie verify REPORT [--repo DIR ...] [--no-fetch] [--access default] [--numbers gap|fail|off] [--json receipt.json]
|
|
4
|
+
[--transcript SESSION.jsonl] [--no-github]
|
|
5
|
+
receipt for a report; exit 1 on FAIL
|
|
6
|
+
buddie mandate queue|accepted|verdict [N ...] [--repo DIR] [--rev COMMIT]
|
|
7
|
+
the work queue itself: done rows anchored, accepted plans quoting the user,
|
|
8
|
+
auto/ask per row; exit 1 when a claim is BROKEN
|
|
9
|
+
buddie journal lessons|measure [PATH ...] [--since TIME] [--sessions 10] [--skip SESSION]
|
|
10
|
+
the gate's journal (files or dirs of *.jsonl; default: the hook's own): what
|
|
11
|
+
past sessions were sent back for (SessionStart prints it), and NEXT №55's measure
|
|
12
|
+
buddie summary [--session ID] [--repo DIR] [--no-queue] [--stale 30] [--text]
|
|
13
|
+
when no subagent is left: what they did, what was lost, the next steps with
|
|
14
|
+
auto/ask and a ready AskUserQuestion card (JSON; --text only the text)
|
|
15
|
+
buddie init [--repo DIR] write buddie.toml with the default levels and the intro mode on
|
|
16
|
+
buddie intro [--false] [--repo DIR]
|
|
17
|
+
where the intro mode stands; --false: the last return was false, restart the count
|
|
18
|
+
buddie enforce [--repo DIR] end the intro mode: returns block from now on
|
|
19
|
+
buddie serve MCP server on stdio (verify_report, snap, source_text, summary)
|
|
20
|
+
buddie serve --http [--host 127.0.0.1] [--port 8000]
|
|
21
|
+
remote MCP server over Streamable HTTP at /mcp (remote.py: no files, no
|
|
22
|
+
local repos, public addresses only)
|
|
23
|
+
buddie hook Claude Code hook: reads the event on stdin, exit 2 blocks a FAIL answer
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_verify(args) -> int:
|
|
35
|
+
from buddie.anchors import find_repos
|
|
36
|
+
from buddie.effects import github_get
|
|
37
|
+
from buddie.verify import verify
|
|
38
|
+
text = sys.stdin.read() if args.report == "-" else Path(args.report).read_text(encoding="utf-8")
|
|
39
|
+
repos = [Path(r) for r in args.repo] if args.repo else find_repos(Path.cwd())
|
|
40
|
+
receipt = verify(text, name=args.report, repos=repos, store=args.store, fetch=not args.no_fetch,
|
|
41
|
+
access=args.access, quotes=not args.anchors_only, rev=args.rev,
|
|
42
|
+
github=None if args.no_github else github_get, transcript=args.transcript,
|
|
43
|
+
numbers=args.numbers)
|
|
44
|
+
pred = receipt["predicate"]
|
|
45
|
+
if args.json:
|
|
46
|
+
Path(args.json).write_text(json.dumps(receipt, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
47
|
+
for item in pred["blocking"]:
|
|
48
|
+
print(f"BLOCK {item}")
|
|
49
|
+
for item in pred["gaps"]:
|
|
50
|
+
print(f"gap {item}")
|
|
51
|
+
for item in pred.get("notes", []):
|
|
52
|
+
print(f"note {item}")
|
|
53
|
+
print(pred["line"])
|
|
54
|
+
return 1 if pred["outcome"] == "FAIL" else 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_mandate(args) -> int:
|
|
58
|
+
from buddie import mandate as man
|
|
59
|
+
from buddie.anchors import find_repos
|
|
60
|
+
repos = [Path(r) for r in args.repo] if args.repo else find_repos(Path.cwd())
|
|
61
|
+
cfg = man.find_config(repos)
|
|
62
|
+
if cfg is None:
|
|
63
|
+
print(f"no {man.CONFIG} with a [mandate] section in {', '.join(map(str, repos)) or 'cwd'}", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
if args.what == "queue":
|
|
66
|
+
out = man.check_queue(cfg, args.rev)
|
|
67
|
+
claims = out["done"]
|
|
68
|
+
elif args.what == "accepted":
|
|
69
|
+
out = claims = man.check_accepted(cfg, args.rev)
|
|
70
|
+
else:
|
|
71
|
+
m = man.Mandate(cfg, args.rev)
|
|
72
|
+
out = [m.verdict(n) for n in (args.rows or [n for n in sorted(m.table)
|
|
73
|
+
if m.table[n]["status"].startswith(cfg["proposed"])])]
|
|
74
|
+
claims = []
|
|
75
|
+
print(json.dumps(out, ensure_ascii=False, indent=1))
|
|
76
|
+
return 1 if man.outcome(claims) == "FAIL" else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_journal(args) -> int:
|
|
80
|
+
from buddie import journal
|
|
81
|
+
from buddie.hook import journal_paths
|
|
82
|
+
rows = journal.entries([Path(p) for p in args.paths] if args.paths else journal_paths())
|
|
83
|
+
if args.what == "lessons":
|
|
84
|
+
print(journal.lessons(rows, skip_session=args.skip))
|
|
85
|
+
else:
|
|
86
|
+
print(json.dumps(journal.measure(rows, args.since, args.sessions), ensure_ascii=False, indent=1))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_summary(args) -> int:
|
|
91
|
+
from buddie.anchors import find_repos
|
|
92
|
+
from buddie.summary import summarize
|
|
93
|
+
repos = [Path(r) for r in args.repo] if args.repo else find_repos(Path.cwd())
|
|
94
|
+
out = summarize(args.session, repos, queue=not args.no_queue, stale=args.stale)
|
|
95
|
+
print(out["text"] if args.text else json.dumps(out, ensure_ascii=False, indent=1))
|
|
96
|
+
if args.text and out["card"]:
|
|
97
|
+
print(json.dumps(out["card"], ensure_ascii=False))
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _config_path(args) -> Path | None:
|
|
102
|
+
from buddie import config
|
|
103
|
+
from buddie.anchors import find_repos
|
|
104
|
+
return config.find([Path(r) for r in args.repo] if args.repo else find_repos(Path.cwd()) or [Path.cwd()])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_init(args) -> int:
|
|
108
|
+
from buddie import config
|
|
109
|
+
from buddie.anchors import find_repos
|
|
110
|
+
repo = Path(args.repo[0]) if args.repo else (find_repos(Path.cwd()) or [Path.cwd()])[0]
|
|
111
|
+
try:
|
|
112
|
+
path = config.init(repo)
|
|
113
|
+
except FileExistsError as e:
|
|
114
|
+
print(f"{e} exists; not overwritten", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
print(f"wrote {path}: intro mode for the next {config.INIT_FINALS} final answers (returns only warn)")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_intro(args, enforce: bool = False) -> int:
|
|
121
|
+
from buddie import config
|
|
122
|
+
path = _config_path(args)
|
|
123
|
+
if path is None:
|
|
124
|
+
print(f"no {config.CONFIG} here: run `buddie init`", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
if enforce:
|
|
127
|
+
config.enforce(path)
|
|
128
|
+
elif args.false:
|
|
129
|
+
config.false_return(path)
|
|
130
|
+
tour = config.intro(path, config.load(path))
|
|
131
|
+
print(f"{path}: " + (f"intro, {tour['seen']} of {tour['finals']} final answers passed without a false return"
|
|
132
|
+
if tour else "enforced: returns block"))
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_serve(args) -> int:
|
|
137
|
+
if args.http:
|
|
138
|
+
from buddie.remote import serve as serve_http
|
|
139
|
+
return serve_http(args.host, args.port)
|
|
140
|
+
from buddie.mcp import serve
|
|
141
|
+
return serve()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_hook(args) -> int:
|
|
145
|
+
from buddie.hook import main
|
|
146
|
+
return main()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: list[str] | None = None) -> int:
|
|
150
|
+
from buddie import __version__
|
|
151
|
+
for stream in (sys.stdout, sys.stderr): # quotes, anchors and reasons are UTF-8 on every OS (Windows: cp1252)
|
|
152
|
+
if hasattr(stream, "reconfigure"):
|
|
153
|
+
stream.reconfigure(encoding="utf-8")
|
|
154
|
+
p = argparse.ArgumentParser(prog="buddie", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
155
|
+
p.add_argument("--version", action="version", version=f"buddie {__version__}")
|
|
156
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
157
|
+
s = sub.add_parser("verify", help="receipt for a report")
|
|
158
|
+
s.add_argument("report", help="Markdown file, or - for stdin")
|
|
159
|
+
s.add_argument("--repo", action="append", help="repo checkout for anchors (repeatable; default: around cwd)")
|
|
160
|
+
s.add_argument("--store", help="snapshot store (default $VERBATIM_STORE or ./.verbatim)")
|
|
161
|
+
s.add_argument("--no-fetch", action="store_true", help="only use snapshots already in the store")
|
|
162
|
+
s.add_argument("--access", help="verbatim access cascade for unreadable pages, e.g. default")
|
|
163
|
+
s.add_argument("--anchors-only", action="store_true", help="skip quotes")
|
|
164
|
+
s.add_argument("--json", help="write the receipt here")
|
|
165
|
+
s.add_argument("--rev", help="judge work claims against the queue as of this commit")
|
|
166
|
+
s.add_argument("--transcript", help="Claude Code session jsonl for run: anchors")
|
|
167
|
+
s.add_argument("--no-github", action="store_true", help="leave pr:/ci: anchors UNCHECKABLE (offline)")
|
|
168
|
+
s.add_argument("--numbers", choices=("gap", "fail", "off"), default="gap",
|
|
169
|
+
help="a number of a cited sentence not in its source: a gap (default), a failure, or not checked")
|
|
170
|
+
s.set_defaults(func=cmd_verify)
|
|
171
|
+
m = sub.add_parser("mandate", help="check the work queue and the accepted plans (buddie.toml [mandate])")
|
|
172
|
+
m.add_argument("what", choices=("queue", "accepted", "verdict"))
|
|
173
|
+
m.add_argument("rows", nargs="*", type=int)
|
|
174
|
+
m.add_argument("--repo", action="append", help="repo checkout with buddie.toml (default: around cwd)")
|
|
175
|
+
m.add_argument("--rev", help="the queue as of this commit")
|
|
176
|
+
m.set_defaults(func=cmd_mandate)
|
|
177
|
+
j = sub.add_parser("journal", help="the gate's journal: lessons for a new session, the closed-loop measure")
|
|
178
|
+
j.add_argument("what", choices=("lessons", "measure"))
|
|
179
|
+
j.add_argument("paths", nargs="*", help="journal files or directories of *.jsonl (default: the hook's own)")
|
|
180
|
+
j.add_argument("--since", default="", help="measure: sessions that started at or after this time")
|
|
181
|
+
j.add_argument("--sessions", type=int, default=10, help="measure: the first N such sessions (0: all)")
|
|
182
|
+
j.add_argument("--skip", help="lessons: leave out this session")
|
|
183
|
+
j.set_defaults(func=cmd_journal)
|
|
184
|
+
u = sub.add_parser("summary", help="summary of the subagents' results and a choice card for the next steps")
|
|
185
|
+
u.add_argument("--session", help="the session (default: the one whose hook state changed last)")
|
|
186
|
+
u.add_argument("--repo", action="append", help="repo checkout with buddie.toml for the queue (default: around cwd)")
|
|
187
|
+
u.add_argument("--no-queue", action="store_true", help="only the steps the subagents named, not the queue's rows")
|
|
188
|
+
u.add_argument("--stale", type=int, default=30, help="minutes of silence before a started subagent counts as lost")
|
|
189
|
+
u.add_argument("--text", action="store_true", help="print the text, then the card as one JSON line")
|
|
190
|
+
u.set_defaults(func=cmd_summary)
|
|
191
|
+
i = sub.add_parser("init", help="write buddie.toml with the default levels and the intro mode on")
|
|
192
|
+
i.add_argument("--repo", action="append", help="where to write it (default: the repo around cwd)")
|
|
193
|
+
i.set_defaults(func=cmd_init)
|
|
194
|
+
t = sub.add_parser("intro", help="where the intro mode stands; --false reports a false return")
|
|
195
|
+
t.add_argument("--false", action="store_true", help="the last return was false: restart the count")
|
|
196
|
+
t.add_argument("--repo", action="append", help="repo with buddie.toml (default: around cwd)")
|
|
197
|
+
t.set_defaults(func=cmd_intro)
|
|
198
|
+
e = sub.add_parser("enforce", help="end the intro mode")
|
|
199
|
+
e.add_argument("--repo", action="append", help="repo with buddie.toml (default: around cwd)")
|
|
200
|
+
e.set_defaults(func=lambda args: cmd_intro(args, enforce=True))
|
|
201
|
+
s = sub.add_parser("serve", help="MCP server on stdio, or over HTTP with --http")
|
|
202
|
+
s.add_argument("--http", action="store_true", help="Streamable HTTP at /mcp instead of stdio (remote.py)")
|
|
203
|
+
s.add_argument("--host", default=os.environ.get("BUDDIE_HTTP_HOST", "127.0.0.1"),
|
|
204
|
+
help="address to listen on (default 127.0.0.1; 0.0.0.0 in a container)")
|
|
205
|
+
s.add_argument("--port", type=int, default=int(os.environ.get("PORT") or 8000), help="default $PORT or 8000")
|
|
206
|
+
s.set_defaults(func=cmd_serve)
|
|
207
|
+
sub.add_parser("hook", help="Claude Code hook on stdin").set_defaults(func=cmd_hook)
|
|
208
|
+
args = p.parse_args(argv)
|
|
209
|
+
try:
|
|
210
|
+
return args.func(args)
|
|
211
|
+
except FileNotFoundError as e:
|
|
212
|
+
if args.cmd not in ("verify", "mandate"):
|
|
213
|
+
raise
|
|
214
|
+
print(f"buddie {args.cmd}: {e}", file=sys.stderr) # a missing report or --rev: usage, not FAIL (NEXT №105)
|
|
215
|
+
return 2
|
buddie/config.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""buddie.toml for the hook (0.8, NEXT №93, audit/20 §3.2–3.3): a level per class of claims, and the intro mode.
|
|
2
|
+
|
|
3
|
+
[gates] sets each class to "block" (the text goes back to the model), "warn" (it leaves, and the person sees what
|
|
4
|
+
the gate would have said, as a systemMessage) or "off" (not checked):
|
|
5
|
+
|
|
6
|
+
anchors a broken anchor (path@commit, path#sha, pr:, ci:, run:) default block
|
|
7
|
+
quotes a quote not on its source default block
|
|
8
|
+
final a final answer with no anchor, or a count line without one default block
|
|
9
|
+
prose work claimed in prose with no anchor on the line default warn
|
|
10
|
+
numbers a number of a cited sentence not in its source default warn
|
|
11
|
+
work a claim about the work queue that does not hold ([mandate]) default block
|
|
12
|
+
summary a Stop returned once for the summary of the subagents' results default off
|
|
13
|
+
promises a Stop returned for a promise whose condition holds default off
|
|
14
|
+
|
|
15
|
+
BUDDIE_GATES="prose=block,summary=block" overrides the file (per class). A project without buddie.toml gets the
|
|
16
|
+
defaults.
|
|
17
|
+
|
|
18
|
+
[intro] finals = N (what `buddie init` writes): until N final answers have passed since the last false return was
|
|
19
|
+
reported (`buddie intro --false`), nothing is returned: each would-be return is a warning that says what it would
|
|
20
|
+
have returned. `buddie enforce` ends the intro at once. The count is kept per buddie.toml in $BUDDIE_INTRO, default
|
|
21
|
+
~/.cache/buddie/intro.json.
|
|
22
|
+
|
|
23
|
+
[journal] text = false (the default, NEXT №104): the hook's journal keeps receipts only (time, tool, counts,
|
|
24
|
+
verdicts, exit code, the sha256 of the text), never words of the conversation. A promise line keeps its kind, row,
|
|
25
|
+
condition and the sha256 of its sentence, not the sentence; a choice line keeps the hashes of the steps the person
|
|
26
|
+
picked and the queue rows of a typed answer, not the questions, options or answer. text = true keeps those words
|
|
27
|
+
(lab's dogfood); BUDDIE_JOURNAL_TEXT=1 or 0 overrides the file.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import tomllib
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
CONFIG = "buddie.toml"
|
|
37
|
+
LEVELS = ("block", "warn", "off")
|
|
38
|
+
DEFAULTS = {"anchors": "block", "quotes": "block", "final": "block", "prose": "warn", "numbers": "warn",
|
|
39
|
+
"work": "block", "summary": "off", "promises": "off"}
|
|
40
|
+
INIT_FINALS = 20
|
|
41
|
+
|
|
42
|
+
TEMPLATE = f"""# buddie config (buddie init). Each class of claims is "block", "warn" or "off".
|
|
43
|
+
[gates]
|
|
44
|
+
anchors = "block" # a broken anchor: path@commit, path#sha256, pr:, ci:, run:
|
|
45
|
+
quotes = "block" # a quote not on its source
|
|
46
|
+
final = "block" # a final answer with no anchor, or a count line without one
|
|
47
|
+
prose = "warn" # "merged", "tests passed" in prose with no anchor on the line
|
|
48
|
+
numbers = "warn" # a number of a cited sentence not in its source
|
|
49
|
+
summary = "off" # return a Stop once for the summary of the subagents' results
|
|
50
|
+
promises = "off" # return a Stop for a promise whose condition holds
|
|
51
|
+
|
|
52
|
+
# The journal keeps receipts only (counts, verdicts, hashes), no words of the conversation; true keeps promise
|
|
53
|
+
# sentences and choice cards in it.
|
|
54
|
+
[journal]
|
|
55
|
+
text = false
|
|
56
|
+
|
|
57
|
+
# Intro mode: nothing is returned until this many final answers have passed without a false return; each
|
|
58
|
+
# would-be return is a warning. Report a false return with `buddie intro --false`; end the intro with `buddie enforce`.
|
|
59
|
+
[intro]
|
|
60
|
+
finals = {INIT_FINALS}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find(repos: list[Path]) -> Path | None:
|
|
65
|
+
for repo in repos:
|
|
66
|
+
if (repo / CONFIG).is_file():
|
|
67
|
+
return repo / CONFIG
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(path: Path | None) -> dict:
|
|
72
|
+
if path is None:
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def of_event(event: dict) -> tuple[Path | None, dict]:
|
|
81
|
+
from buddie.promises import repos_of
|
|
82
|
+
path = find(repos_of(event))
|
|
83
|
+
return path, load(path)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def levels(cfg: dict) -> dict[str, str]:
|
|
87
|
+
out = dict(DEFAULTS)
|
|
88
|
+
for k, v in (cfg.get("gates") or {}).items():
|
|
89
|
+
if k in out and v in LEVELS:
|
|
90
|
+
out[k] = v
|
|
91
|
+
for pair in os.environ.get("BUDDIE_GATES", "").split(","):
|
|
92
|
+
k, _, v = pair.strip().partition("=")
|
|
93
|
+
if k in out and v.strip() in LEVELS:
|
|
94
|
+
out[k] = v.strip()
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def numbers_mode(level: str) -> str:
|
|
99
|
+
"""The verify mode a numbers level needs: a warning is a blocking item demoted by the gate."""
|
|
100
|
+
return {"block": "fail", "warn": "fail", "off": "off"}[level]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def classify(item: str) -> str | None:
|
|
104
|
+
"""The class of one blocking item of the gate (verify's blocking strings, hook.unbacked, hook.prose_claims)."""
|
|
105
|
+
if item.startswith("quote "):
|
|
106
|
+
return "quotes"
|
|
107
|
+
if item.startswith("anchor line "):
|
|
108
|
+
return "anchors"
|
|
109
|
+
if item.startswith(("final answer has no anchor", "number without anchor")):
|
|
110
|
+
return "final"
|
|
111
|
+
if item.startswith("number "):
|
|
112
|
+
return "numbers"
|
|
113
|
+
if item.startswith("work claimed in prose"):
|
|
114
|
+
return "prose"
|
|
115
|
+
if " line " in item.split(":", 1)[0]:
|
|
116
|
+
return "work"
|
|
117
|
+
return None # the context gate and anything new: always block
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def journal_text(cfg: dict) -> bool:
|
|
121
|
+
"""Whether the journal may keep words of the conversation (promise sentences, choice cards); default no."""
|
|
122
|
+
env = os.environ.get("BUDDIE_JOURNAL_TEXT")
|
|
123
|
+
if env in ("0", "1"):
|
|
124
|
+
return env == "1"
|
|
125
|
+
return (cfg.get("journal") or {}).get("text") is True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _intro_file() -> Path | None:
|
|
129
|
+
target = os.environ.get("BUDDIE_INTRO", str(Path.home() / ".cache" / "buddie" / "intro.json"))
|
|
130
|
+
return None if target in ("", "0") else Path(target)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _intro_state() -> dict:
|
|
134
|
+
f = _intro_file()
|
|
135
|
+
try:
|
|
136
|
+
return json.loads(f.read_text(encoding="utf-8")) if f and f.is_file() else {}
|
|
137
|
+
except (OSError, json.JSONDecodeError):
|
|
138
|
+
return {}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _save(state: dict) -> None:
|
|
142
|
+
f = _intro_file()
|
|
143
|
+
if f is None:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
f.write_text(json.dumps(state, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _key(path: Path) -> str:
|
|
153
|
+
return str(path.resolve())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def intro(path: Path | None, cfg: dict) -> dict | None:
|
|
157
|
+
"""The intro of this buddie.toml while it lasts: {finals, seen}; None once enforced or with no [intro]."""
|
|
158
|
+
n = (cfg.get("intro") or {}).get("finals")
|
|
159
|
+
if path is None or not isinstance(n, int) or n <= 0:
|
|
160
|
+
return None
|
|
161
|
+
mine = _intro_state().get(_key(path), {})
|
|
162
|
+
if mine.get("enforced") or mine.get("seen", 0) >= n:
|
|
163
|
+
return None
|
|
164
|
+
return {"finals": n, "seen": mine.get("seen", 0)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def count_final(path: Path) -> int:
|
|
168
|
+
state = _intro_state()
|
|
169
|
+
mine = state.setdefault(_key(path), {})
|
|
170
|
+
mine["seen"] = mine.get("seen", 0) + 1
|
|
171
|
+
_save(state)
|
|
172
|
+
return mine["seen"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def false_return(path: Path) -> None:
|
|
176
|
+
state = _intro_state()
|
|
177
|
+
mine = state.setdefault(_key(path), {})
|
|
178
|
+
mine["seen"], mine["false"] = 0, mine.get("false", 0) + 1
|
|
179
|
+
_save(state)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def enforce(path: Path) -> None:
|
|
183
|
+
state = _intro_state()
|
|
184
|
+
state.setdefault(_key(path), {})["enforced"] = True
|
|
185
|
+
_save(state)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def init(repo: Path) -> Path:
|
|
189
|
+
path = repo / CONFIG
|
|
190
|
+
if path.exists():
|
|
191
|
+
raise FileExistsError(path)
|
|
192
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
193
|
+
return path
|