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 ADDED
@@ -0,0 +1,3 @@
1
+ """buddie: an evidence layer for orchestrator/executor chats (audit/08). Engine: verbatim + anchors."""
2
+
3
+ __version__ = "1.0.0"
buddie/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from buddie.cli import main
4
+
5
+ sys.exit(main())
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