contribos 0.2.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.
contribos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ContribOS: help new contributors earn trust in open source."""
2
+
3
+ __version__ = "0.2.0"
contribos/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,148 @@
1
+ """Agent guardrails: make your coding agent follow this repo's rules, where slop starts.
2
+
3
+ Writes, into your local checkout:
4
+ - a skill (`.claude/skills/contribos/SKILL.md`, mirrored to `.agents/skills/`)
5
+ that Claude Code, Codex, Copilot and Cursor load, with this repo's rules;
6
+ - a Claude Code hook that blocks the agent from opening PRs or posting
7
+ comments on its own;
8
+ - a git `commit-msg` hook that enforces sign-off and the AI-disclosure line
9
+ this repo asks for.
10
+
11
+ None of these files are part of your contribution: they're added to
12
+ `.git/info/exclude` so they never end up in your PR.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import stat
19
+ from pathlib import Path
20
+
21
+ from .policy import Policy, structured
22
+ from .setup_doctor import SetupPlan
23
+
24
+ GUARD = '''#!/usr/bin/env python3
25
+ """ContribOS guard: stop the coding agent from acting publicly on its own."""
26
+ import json, re, sys
27
+
28
+ data = json.load(sys.stdin)
29
+ cmd = (data.get("tool_input") or {}).get("command", "")
30
+ if re.search(r"\\bgh\\s+(pr\\s+(create|comment|review|merge|edit|ready|close|reopen)|"
31
+ r"issue\\s+(create|comment|edit|close|reopen)|"
32
+ r"api\\b.*(-X|--method)\\s*(POST|PATCH|PUT|DELETE))", cmd, re.I):
33
+ print("ContribOS: opening PRs, posting comments or claiming issues must be done by the human "
34
+ "contributor, not the agent. Prepare the text and let the human post it.", file=sys.stderr)
35
+ sys.exit(2)
36
+ sys.exit(0)
37
+ '''
38
+
39
+ COMMIT_MSG = '''#!/bin/sh
40
+ # ContribOS commit-msg hook: enforce this repo's sign-off and AI-disclosure rules.
41
+ msg="$1"
42
+ {dco}
43
+ if [ -f "$(git rev-parse --git-dir)/contribos-ai-used" ] && [ -n "{trailer}" ]; then
44
+ if ! grep -qi "^{trailer}:" "$msg"; then
45
+ echo "ContribOS: this repo asks for a '{trailer}' line when AI helped. Add, for example:" >&2
46
+ echo " {trailer}: $(cat "$(git rev-parse --git-dir)/contribos-ai-used")" >&2
47
+ exit 1
48
+ fi
49
+ fi
50
+ exit 0
51
+ '''
52
+
53
+ DCO_CHECK = '''if ! grep -q "^Signed-off-by:" "$msg"; then
54
+ echo "ContribOS: this repo requires sign-off. Commit with: git commit -s" >&2
55
+ exit 1
56
+ fi'''
57
+
58
+
59
+ def skill_text(policy: Policy, setup: SetupPlan | None) -> str:
60
+ s = structured(policy)
61
+ ai = s["ai"]
62
+ rules = ["Never open pull requests, post comments, or claim issues yourself. Draft text; the human posts it.",
63
+ "Keep the change minimal and focused on the issue. Do not refactor unrelated code.",
64
+ "The human must understand every line. Explain your changes plainly and point out anything uncertain.",
65
+ "Never write the human's PR explanation, issue comments or review replies as final text; offer a "
66
+ "short draft they will rewrite."]
67
+ if ai["stance"] == "restricted":
68
+ rules.append("This repo restricts AI use. Read the AI policy evidence below and stop if the task is not allowed.")
69
+ if s["trust_gate"]:
70
+ rules.append("This repo only accepts PRs from vouched contributors. If the human isn't vouched yet, "
71
+ "help them write a short introduction instead of code.")
72
+ if ai["no_ai_on_good_first_issues"]:
73
+ rules.append("Do not work on issues labelled good-first-issue: this repo reserves them for humans learning.")
74
+ if ai["disclosure_trailer"]:
75
+ rules.append(f"Every commit you help with must include a `{ai['disclosure_trailer']}` line naming the tool.")
76
+ elif ai["disclose_in_pr"]:
77
+ rules.append("Remind the human to disclose in the PR description which tool was used and for what.")
78
+ if s["dco"]:
79
+ rules.append("Commits must be signed off by the human (`git commit -s`). Never add Signed-off-by yourself.")
80
+ if s["tests_expected"]:
81
+ rules.append("Add or update a test that fails without the fix.")
82
+ if s["changelog"]:
83
+ rules.append("Add a changelog entry in the format the repo uses.")
84
+ if s["issue_first"] or s["claim_first"]:
85
+ rules.append("Only work on an issue a maintainer has agreed to. If unsure, stop and ask the human.")
86
+ steps = []
87
+ if setup:
88
+ for st in setup.steps:
89
+ if st.commands and ("test" in st.title.lower() or "lint" in st.title.lower() or "pre-commit" in st.title.lower()):
90
+ steps += [f"`{c}`" for c in st.commands[:2]]
91
+ L = ["---", "name: contribos", "description: Rules for contributing to this open-source repository. Use before "
92
+ "writing or committing any change here.", "---", "",
93
+ f"# Contributing to {policy.repo}", "",
94
+ "You are helping a human contributor. Maintainers here review human work and reject unexplained AI output.",
95
+ "", "## Rules", ""] + [f"- {r}" for r in rules]
96
+ if steps:
97
+ L += ["", "## Run before saying you're done", ""] + [f"- {c}" for c in steps]
98
+ L += ["", "## Tools", "",
99
+ "If the ContribOS MCP server is available, use `contribos_brief` to find relevant files and past PRs, "
100
+ "and `contribos_check` before the human submits.", "", "## Evidence for these rules", ""]
101
+ for f in policy.findings[:8]:
102
+ src = f" ({f.evidence[0].source})" if f.evidence else ""
103
+ L.append(f"- {f.topic}: {f.verdict}{src}")
104
+ return "\n".join(L) + "\n"
105
+
106
+
107
+ def install(root: Path, policy: Policy, setup: SetupPlan | None, ai_note: str | None = None) -> list[str]:
108
+ written = []
109
+ text = skill_text(policy, setup)
110
+ for d in (root / ".claude/skills/contribos", root / ".agents/skills/contribos"):
111
+ d.mkdir(parents=True, exist_ok=True)
112
+ (d / "SKILL.md").write_text(text)
113
+ written.append((d / "SKILL.md").relative_to(root).as_posix())
114
+
115
+ guard = root / ".contribos/guard.py"
116
+ guard.parent.mkdir(exist_ok=True)
117
+ guard.write_text(GUARD)
118
+ guard.chmod(guard.stat().st_mode | stat.S_IEXEC)
119
+ written.append(".contribos/guard.py")
120
+ settings_path = root / ".claude/settings.local.json"
121
+ settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
122
+ hooks = settings.setdefault("hooks", {}).setdefault("PreToolUse", [])
123
+ entry = {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 .contribos/guard.py"}]}
124
+ if entry not in hooks:
125
+ hooks.append(entry)
126
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
127
+ written.append(".claude/settings.local.json")
128
+
129
+ s = structured(policy)
130
+ git_dir = root / ".git"
131
+ if git_dir.is_dir():
132
+ hook = git_dir / "hooks" / "commit-msg"
133
+ target = hook if not hook.exists() or "ContribOS" in hook.read_text() else hook.with_name("commit-msg.contribos")
134
+ target.write_text(COMMIT_MSG.format(dco=DCO_CHECK if s["dco"] else "", trailer=s["ai"]["disclosure_trailer"] or ""))
135
+ target.chmod(target.stat().st_mode | stat.S_IEXEC)
136
+ written.append(target.relative_to(root).as_posix())
137
+ marker = git_dir / "contribos-ai-used"
138
+ if ai_note:
139
+ marker.write_text(ai_note + "\n")
140
+ exclude = git_dir / "info" / "exclude"
141
+ exclude.parent.mkdir(exist_ok=True)
142
+ current = exclude.read_text() if exclude.exists() else ""
143
+ add = [p for p in (".claude/skills/contribos/", ".agents/skills/contribos/", ".contribos/",
144
+ ".claude/settings.local.json") if p not in current]
145
+ if add:
146
+ exclude.write_text(current.rstrip("\n") + ("\n" if current else "") + "# ContribOS (local only)\n"
147
+ + "\n".join(add) + "\n")
148
+ return written
contribos/bench.py ADDED
@@ -0,0 +1,80 @@
1
+ """History benchmark: replay past merged changes and check whether the brief finds their files.
2
+
3
+ For each sampled change we rebuild the brief at its parent commit, using only
4
+ history older than the change, and the PR title as a stand-in for the issue.
5
+ Titles are shorter and more precise than real issues, so treat scores as an
6
+ upper-bound smoke test; the real benchmark uses linked issue text (needs the
7
+ GitHub API).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from dataclasses import dataclass
14
+
15
+ from . import brief as brief_mod
16
+ from .policy import is_code
17
+ from .precedent import Change
18
+ from .repo import Repo
19
+
20
+
21
+ @dataclass
22
+ class Case:
23
+ change: Change
24
+ code_files: list[str]
25
+
26
+
27
+ @dataclass
28
+ class Result:
29
+ case: Case
30
+ full_hit: bool
31
+ full_recall: float
32
+ grep_hit: bool
33
+ grep_recall: float
34
+
35
+
36
+ def sample(history: list[Change], n: int, seed: int = 7) -> list[Case]:
37
+ pool = []
38
+ for i, c in enumerate(history[:-50]): # keep older history as precedent
39
+ code = [f for f in c.files if is_code(f)]
40
+ if c.pr and 1 <= len(code) <= 4 and len(c.title.split()) >= 4 \
41
+ and not c.title.lower().startswith(("bump", "release", "merge", "revert", "update dependencies")):
42
+ pool.append(Case(c, code))
43
+ random.Random(seed).shuffle(pool)
44
+ return pool[:n]
45
+
46
+
47
+ def _score(top: list[str], truth: list[str]) -> tuple[bool, float]:
48
+ found = len(set(top) & set(truth))
49
+ return found > 0, found / len(truth)
50
+
51
+
52
+ def run(repo: Repo, history: list[Change], n: int = 20, k: int = 5, log=print) -> list[Result]:
53
+ results = []
54
+ order = {c.sha: i for i, c in enumerate(history)}
55
+ for case in sample(history, n):
56
+ c = case.change
57
+ parent = f"{c.sha}^1"
58
+ older = history[order[c.sha] + 1:]
59
+ full = brief_mod.build(repo, c.title, "", older, None, rev=parent, top=k)
60
+ grep = brief_mod.build(repo, c.title, "", [], None, rev=parent, top=k)
61
+ fh, fr = _score([h.path for h in full.files], case.code_files)
62
+ gh, gr = _score([h.path for h in grep.files], case.code_files)
63
+ results.append(Result(case, fh, fr, gh, gr))
64
+ log(f" #{c.pr} {'✓' if fh else '·'} {'✓' if gh else '·'} {_short(c.title, 70)}")
65
+ return results
66
+
67
+
68
+ def summary(results: list[Result]) -> dict:
69
+ n = len(results) or 1
70
+ return {
71
+ "cases": len(results),
72
+ "brief_hit@5": sum(r.full_hit for r in results) / n,
73
+ "brief_recall@5": sum(r.full_recall for r in results) / n,
74
+ "grep_only_hit@5": sum(r.grep_hit for r in results) / n,
75
+ "grep_only_recall@5": sum(r.grep_recall for r in results) / n,
76
+ }
77
+
78
+
79
+ def _short(text: str, n: int) -> str:
80
+ return text if len(text) <= n else text[:n - 1].rstrip() + "…"
contribos/brief.py ADDED
@@ -0,0 +1,299 @@
1
+ """Contribution brief: where to look, how this repo did it before, and what to watch for.
2
+
3
+ Ranking is deterministic and every file comes with the reasons it was picked:
4
+ term matches in the code, term matches in its path, and similar past changes
5
+ that touched it. No LLM is needed for the ranking itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from collections import Counter, defaultdict
13
+ from dataclasses import dataclass, field
14
+
15
+ from .precedent import Change
16
+ from .repo import Repo
17
+ from .policy import is_code, is_test, Policy
18
+
19
+ STOP = set("""
20
+ a an and are as at be been but by can could did do does doesn't don't for from get gets
21
+ got has have having how i if in into is isn't it it's its just like make makes may me might
22
+ more most my no not now of on one or our out should so some such than that the their them
23
+ then there these they this those to too up us use used using via was we were what when where
24
+ which while who why will with without would you your also only any all each other same very
25
+ issue bug fix fixed fixes problem error errors expected actual behavior behaviour steps
26
+ reproduce version works working work please thanks thank hi hello example code case cases
27
+ new need needs want run running returns return value true false none null
28
+ """.split())
29
+ SKIP_DIRS = ("node_modules/", "vendor/", "third_party/", "dist/", "build/", ".git/")
30
+ LOOKALIKE_DIRS = ("legacy", "deprecated", "compat", "examples", "example", "docs", "doc",
31
+ "migrations", "benchmarks", "vendor", "_vendor", "contrib")
32
+ IDENT = re.compile(r"`([^`\n]{2,80})`|([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)+)|"
33
+ r"\b([a-z]+_[a-z_0-9]+)\b|\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+)\b|"
34
+ r"\b(\w+(?:Error|Exception|Warning))\b")
35
+ PRECEDENT_WEIGHT = float(__import__("os").environ.get("CONTRIBOS_PW", "2"))
36
+ WORD = re.compile(r"[A-Za-z][A-Za-z0-9]{3,}")
37
+
38
+
39
+ @dataclass
40
+ class Term:
41
+ text: str
42
+ weight: float # identifiers count more than plain words
43
+
44
+
45
+ @dataclass
46
+ class FileHit:
47
+ path: str
48
+ score: float = 0.0
49
+ reasons: list[str] = field(default_factory=list)
50
+
51
+
52
+ @dataclass
53
+ class Brief:
54
+ repo: str
55
+ title: str
56
+ terms: list[Term]
57
+ files: list[FileHit]
58
+ tests: list[FileHit]
59
+ precedents: list[tuple[float, Change]]
60
+ lookalikes: list[tuple[str, str]]
61
+ questions: list[str]
62
+ scope_hint: str | None
63
+
64
+
65
+ def extract_terms(text: str, limit: int = 20) -> list[Term]:
66
+ idents: Counter[str] = Counter()
67
+ for m in IDENT.finditer(text):
68
+ tok = next(g for g in m.groups() if g)
69
+ tok = tok.strip().strip("()")
70
+ if len(tok) < 3 or " " in tok and len(tok.split()) > 3:
71
+ continue
72
+ idents[tok] += 1
73
+ # Also index the last part of dotted names (module.func -> func).
74
+ if "." in tok and not tok.endswith((".py", ".js", ".ts", ".md")):
75
+ idents[tok.rsplit(".", 1)[-1]] += 1
76
+ words = Counter(w.lower() for w in WORD.findall(text) if w.lower() not in STOP)
77
+ terms: dict[str, Term] = {}
78
+ for tok, n in idents.most_common():
79
+ if tok.lower() in STOP:
80
+ continue
81
+ terms.setdefault(tok.lower(), Term(tok, 2.0 + min(n, 3) * 0.3))
82
+ for w, n in words.most_common():
83
+ if w not in terms:
84
+ terms[w] = Term(w, 1.0 + min(n, 3) * 0.2)
85
+ return sorted(terms.values(), key=lambda t: -t.weight)[:limit]
86
+
87
+
88
+ def _tokens(text: str) -> set[str]:
89
+ return {w.lower() for w in re.findall(r"[A-Za-z][A-Za-z0-9]{2,}", text)} - STOP
90
+
91
+
92
+ def _path_tokens(path: str) -> set[str]:
93
+ parts = re.split(r"[/_.\-]", path.lower())
94
+ return {p for p in parts if len(p) > 2}
95
+
96
+
97
+ def rank_precedents(terms: list[Term], history: list[Change], k: int = 8) -> list[tuple[float, Change]]:
98
+ """BM25-style match of issue terms against past change titles and bodies."""
99
+ docs = [(c, _tokens(f"{c.title} {c.title} {c.body[:600]}")) for c in history]
100
+ n = len(docs) or 1
101
+ df: Counter[str] = Counter()
102
+ for _, toks in docs:
103
+ df.update(toks)
104
+ qterms = {}
105
+ for t in terms:
106
+ for tok in _tokens(t.text) | ({t.text.lower()} if "_" in t.text else set()):
107
+ qterms[tok] = max(qterms.get(tok, 0), t.weight)
108
+ scored = []
109
+ for c, toks in docs:
110
+ s = sum(w * math.log(1 + n / (1 + df[q])) for q, w in qterms.items() if q in toks)
111
+ if s > 0 and c.files:
112
+ scored.append((s / math.sqrt(1 + len(toks) / 20), c))
113
+ scored.sort(key=lambda x: -x[0])
114
+ return scored[:k]
115
+
116
+
117
+ def build(repo: Repo, title: str, body: str, history: list[Change], policy: Policy | None,
118
+ rev: str = "HEAD", top: int = 5) -> Brief:
119
+ terms = extract_terms(f"{title}\n{title}\n{body}")
120
+ all_files = [p for p in repo.files(rev) if not p.startswith(SKIP_DIRS)]
121
+ candidates = [p for p in all_files if is_code(p) or is_test(p)]
122
+ cand_set = set(candidates)
123
+ n_files = len(candidates) or 1
124
+ hits: dict[str, FileHit] = {}
125
+
126
+ def hit(path: str) -> FileHit:
127
+ return hits.setdefault(path, FileHit(path))
128
+
129
+ # 1. Content matches, weighted by how rare the term is in the repo.
130
+ for t in terms[:15]:
131
+ counts = {p: c for p, c in repo.grep(t.text, rev).items() if p in cand_set}
132
+ if not counts or len(counts) > n_files * 0.3:
133
+ continue # absent, or too common to say anything
134
+ idf = math.log(n_files / len(counts))
135
+ for p, c in counts.items():
136
+ h = hit(p)
137
+ h.score += t.weight * idf * math.log(1 + c)
138
+ if len(h.reasons) < 6:
139
+ h.reasons.append(f"mentions `{t.text}` ({c}×)")
140
+
141
+ # 2. Path matches.
142
+ for p in candidates:
143
+ ptoks = _path_tokens(p)
144
+ matched = [t.text for t in terms if t.text.lower() in ptoks]
145
+ if matched:
146
+ h = hit(p)
147
+ h.score += 2.0 * sum(1 for _ in matched)
148
+ h.reasons.append("path matches " + ", ".join(f"'{m}'" for m in matched[:3]))
149
+
150
+ # 3. Files touched by similar past changes.
151
+ precedents = rank_precedents(terms, history)
152
+ if precedents:
153
+ best = precedents[0][0]
154
+ for s, c in precedents:
155
+ for p in c.files:
156
+ if p in cand_set:
157
+ h = hit(p)
158
+ h.score += PRECEDENT_WEIGHT * s / best
159
+ label = f"PR #{c.pr}" if c.pr else c.sha[:8]
160
+ h.reasons.append(f"changed by similar {label}: “{_short(c.title, 70)}”")
161
+
162
+ ranked = sorted(hits.values(), key=lambda h: -h.score)
163
+ code = [h for h in ranked if is_code(h.path)][:top]
164
+ tests = _related_tests(code, ranked, history)
165
+
166
+ lookalikes = []
167
+ top_paths = {h.path for h in code}
168
+ for h in ranked[: top * 4]:
169
+ if h.path in top_paths or not is_code(h.path):
170
+ continue
171
+ dirs = set(h.path.lower().split("/")[:-1])
172
+ if dirs & set(LOOKALIKE_DIRS):
173
+ lookalikes.append((h.path, "matches the issue's words but lives in "
174
+ f"'{'/'.join(sorted(dirs & set(LOOKALIKE_DIRS)))}', which is rarely the place for a fix"))
175
+ elif all(r.startswith("path matches") for r in h.reasons):
176
+ lookalikes.append((h.path, "name looks related, but no code or past change links it to this issue"))
177
+ if len(lookalikes) >= 3:
178
+ break
179
+
180
+ scope_hint = None
181
+ sizes = sorted(len([f for f in c.files if is_code(f)]) for _, c in precedents[:5] if c.files)
182
+ if sizes:
183
+ median = sizes[len(sizes) // 2]
184
+ scope_hint = (f"Similar past changes touched a median of {median} code file"
185
+ f"{'s' if median != 1 else ''}. If yours grows well past that, expect questions about scope.")
186
+
187
+ return Brief(repo.slug, title, terms, code, tests, precedents[:5], lookalikes,
188
+ _questions(policy, precedents, scope_hint), scope_hint)
189
+
190
+
191
+ def _related_tests(code: list[FileHit], ranked: list[FileHit], history: list[Change]) -> list[FileHit]:
192
+ """Tests that co-change with the top files, or share their names."""
193
+ top = {h.path for h in code}
194
+ co: dict[str, Counter[str]] = defaultdict(Counter)
195
+ for c in history:
196
+ tfiles = [f for f in c.files if is_test(f)]
197
+ for f in c.files:
198
+ if f in top:
199
+ co[f].update(tfiles)
200
+ out: dict[str, FileHit] = {}
201
+ for rank, src in enumerate(h.path for h in code):
202
+ stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
203
+ total = sum(co[src].values()) or 1
204
+ weight = 1 / (1 + rank) # tests of the top file matter most
205
+ for t, n in co[src].most_common(2):
206
+ h = out.setdefault(t, FileHit(t))
207
+ h.score += weight * n / total
208
+ h.reasons.append(f"changed together with `{src}` in {n} past change{'s' if n > 1 else ''}")
209
+ for h2 in ranked:
210
+ if is_test(h2.path) and stem in h2.path.lower() and len(stem) > 3:
211
+ h = out.setdefault(h2.path, FileHit(h2.path))
212
+ h.score += 1.5 * weight
213
+ h.reasons.append(f"named after `{src}`")
214
+ for h2 in ranked:
215
+ if is_test(h2.path) and h2.path not in out and len(out) < 4:
216
+ h = out.setdefault(h2.path, FileHit(h2.path, h2.score * 0.1))
217
+ h.reasons.extend(h2.reasons[:2])
218
+ return sorted(out.values(), key=lambda h: -h.score)[:4]
219
+
220
+
221
+ def _questions(policy: Policy | None, precedents, scope_hint) -> list[str]:
222
+ q = ["What exactly was broken, and how does your test show it is fixed now?"]
223
+ if policy and (policy.get("Tests") or (policy.get("Tests in practice")
224
+ and "%" in policy.get("Tests in practice").verdict)):
225
+ q.append("Where is the regression test, and does it fail without your change?")
226
+ if scope_hint:
227
+ q.append("Why does this change need every file it touches?")
228
+ if precedents:
229
+ c = precedents[0][1]
230
+ ref = f"#{c.pr}" if c.pr else c.sha[:8]
231
+ q.append(f"How does this relate to {ref} (“{_short(c.title, 60)}”), and does it follow the same approach?")
232
+ if policy and policy.get("Changelog"):
233
+ q.append("Did you add a changelog entry?")
234
+ if policy and policy.get("AI policy") and "No written" not in policy.get("AI policy").verdict:
235
+ q.append("Did you use AI, and did you disclose it as the project asks?")
236
+ return q
237
+
238
+
239
+ def to_markdown(b: Brief, policy: Policy | None, issue_url: str | None = None,
240
+ review_quotes: dict[int, list[str]] | None = None) -> str:
241
+ L = [f"# Contribution brief: {b.title}", ""]
242
+ L.append(f"Repository: `{b.repo}`" + (f" · Issue: {issue_url}" if issue_url else ""))
243
+ L.append("")
244
+ L.append("Search terms taken from the issue: " + ", ".join(f"`{t.text}`" for t in b.terms[:10]))
245
+ L += ["", "## Where to look", ""]
246
+ if not b.files:
247
+ L.append("No strong matches. The issue may need clarifying before you start; ask the maintainer.")
248
+ for i, h in enumerate(b.files, 1):
249
+ L.append(f"{i}. **`{h.path}`**")
250
+ for r in _dedupe(h.reasons)[:4]:
251
+ L.append(f" - {r}")
252
+ L += ["", "## How this repo handled similar changes", ""]
253
+ if not b.precedents:
254
+ L.append("No similar past changes found in recent history.")
255
+ for _, c in b.precedents:
256
+ ref = f"PR #{c.pr}" if c.pr else f"commit {c.sha[:8]}"
257
+ link = f"https://github.com/{b.repo}/pull/{c.pr}" if c.pr else f"https://github.com/{b.repo}/commit/{c.sha}"
258
+ code = [f for f in c.files if is_code(f)]
259
+ tests = [f for f in c.files if is_test(f)]
260
+ other = [f for f in c.files if f not in code and f not in tests]
261
+ L.append(f"- [{ref}]({link}): “{c.title}”. Touched {len(code)} code, {len(tests)} test and "
262
+ f"{len(other)} other file(s): "
263
+ + ", ".join(f"`{f}`" for f in (code + tests + other)[:4]) + ("…" if len(c.files) > 4 else ""))
264
+ for quote in (review_quotes or {}).get(c.pr or -1, [])[:2]:
265
+ L.append(f" - Reviewer said: “{quote}”")
266
+ if b.scope_hint:
267
+ L += ["", f"**Scope:** {b.scope_hint}"]
268
+ L += ["", "## Tests to read and run", ""]
269
+ if not b.tests:
270
+ L.append("No related tests found. Ask where a test for this should live.")
271
+ for h in b.tests:
272
+ L.append(f"- `{h.path}`: " + "; ".join(_dedupe(h.reasons)[:2]))
273
+ if b.lookalikes:
274
+ L += ["", "## Probably don't touch", ""]
275
+ for p, why in b.lookalikes:
276
+ L.append(f"- `{p}`: {why}")
277
+ if policy:
278
+ L += ["", "## House rules that apply", ""]
279
+ for f in policy.findings:
280
+ src = f" ({f.evidence[0].source})" if f.evidence else ""
281
+ L.append(f"- **{f.topic}:** {f.verdict}{src}")
282
+ L += ["", "## Questions a reviewer will likely ask", ""]
283
+ L += [f"- {q}" for q in b.questions]
284
+ L += ["", "---", "_Every file above is listed with the evidence that selected it. "
285
+ "Check it yourself; ContribOS points, you decide._"]
286
+ return "\n".join(L)
287
+
288
+
289
+ def _dedupe(items: list[str]) -> list[str]:
290
+ seen, out = set(), []
291
+ for i in items:
292
+ if i not in seen:
293
+ seen.add(i)
294
+ out.append(i)
295
+ return out
296
+
297
+
298
+ def _short(text: str, n: int) -> str:
299
+ return text if len(text) <= n else text[:n - 1].rstrip() + "…"