From 51e9032b81a8e4d2659c2fa32925390b48ba7cf3 Mon Sep 17 00:00:00 2001 From: matcha Date: Mon, 5 Oct 2026 20:11:48 +0000 Subject: [PATCH] Add automatic GitHub opportunity scout --- scout.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 scout.py diff --git a/scout.py b/scout.py new file mode 100644 index 0000000..02ce031 --- /dev/null +++ b/scout.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import html +import json +import math +import pathlib +import urllib.request +import urllib.error + +ROOT = pathlib.Path(__file__).resolve().parent +WATCHED = [ + "openai/codex", + "anthropics/claude-code", + "vercel-labs/agent-browser", + "browser-use/browser-use", + "block/goose", + "modelcontextprotocol/servers", +] +KEYWORDS = { + "browser": 9, "upload": 12, "file": 3, "preview": 10, "localhost": 8, + "session": 7, "context": 9, "compact": 10, "goal": 10, "plan": 7, + "mcp": 9, "sandbox": 10, "permission": 9, "approval": 8, "guardrail": 10, + "model": 5, "effort": 8, "preset": 10, "workflow": 6, "task": 5, + "project": 5, "sidebar": 5, "hook": 7, "export": 9, "integration": 5, + "extension": 7, "plugin": 8, "api": 5, "cli": 4, "agent": 5, +} + +def api(url): + req = urllib.request.Request(url, headers={ + "User-Agent": "notplanned-scout/0.1", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) + +def score_issue(issue, now): + text = (issue.get("title","") + " " + (issue.get("body") or "")).lower() + closed = issue.get("closed_at") or issue.get("updated_at") + try: + when = dt.datetime.fromisoformat(closed.replace("Z","+00:00")) + age = max(0, (now - when).days) + except Exception: + age = 365 + recency = max(0, 90 - age) + comments = min(24, int(issue.get("comments") or 0) * 2) + reactions = issue.get("reactions") or {} + reaction_score = min(24, int(reactions.get("total_count") or 0) * 3) + keyword_score = sum(weight for word, weight in KEYWORDS.items() if word in text) + return recency + comments + reaction_score + min(48, keyword_score) + +def suggested_move(title): + t = title.lower() + if "upload" in t or "file input" in t: + return "Ship a deterministic upload bridge outside upstream." + if "preview" in t or "localhost" in t: + return "Ship a preview/port sidecar and test it on real agent-built apps." + if "context" in t or "compact" in t: + return "Build a transcript/context sidecar; measure saved tokens and resumability." + if "goal" in t or "session" in t: + return "Wrap session state externally; prove persistence across runs." + if "model" in t or "effort" in t or "preset" in t: + return "Compose the existing knobs into one named preset action." + if "permission" in t or "sandbox" in t or "approval" in t or "guardrail" in t: + return "Put a narrow authority policy in front of the existing agent." + if "browser" in t or "domain" in t or "form" in t: + return "Wrap browser actions with scoped policy + receipts." + if "project" in t or "task" in t or "workflow" in t: + return "Ship an external supervisor instead of waiting for upstream PM." + return "Find the smallest external wrapper that satisfies the request; test one user." + +def esc(s): + return html.escape(str(s), quote=True) + +def curated_card(o): + return f""" +
{esc(o['repo'])} · #{esc(o['issue'])}{esc(o['heat'])}
+

{esc(o['name'])}

+
{esc(o['title'])}
+

{esc(o['thesis'])}

+
+
signal{esc(o['signal'])}
+
move{esc(o['move'])}
+
+
""" + +def fresh_card(o): + heat = "hot" if o["score"] >= 105 else ("warm" if o["score"] >= 75 else "watch") + return f""" +
{esc(o['repo'])} · #{esc(o['number'])}{heat}
+

{esc(o['title'])}

+
auto score {o['score']} · {esc(o.get('closed_at','')[:10])}
+
{esc(suggested_move(o['title']))}
+
""" + +def find_fresh(curated_urls, limit=6): + now = dt.datetime.now(dt.timezone.utc) + found = [] + for repo in WATCHED: + url = f"https://api.github.com/repos/{repo}/issues?state=closed&sort=updated&direction=desc&per_page=100" + try: + items = api(url) + except Exception as e: + print(f"scout warning: {repo}: {type(e).__name__}", flush=True) + continue + for issue in items: + if "pull_request" in issue: + continue + if issue.get("state_reason") != "not_planned": + continue + if issue.get("html_url") in curated_urls: + continue + score = score_issue(issue, now) + if score < 45: + continue + found.append({ + "repo": repo, + "number": issue.get("number"), + "title": issue.get("title",""), + "html_url": issue.get("html_url",""), + "closed_at": issue.get("closed_at") or issue.get("updated_at") or "", + "score": score, + }) + found.sort(key=lambda x: (x["score"], x["closed_at"]), reverse=True) + return found[:limit] + +def render(out_path, no_network=False): + data = json.loads((ROOT / "opportunities.json").read_text()) + curated = data["opportunities"] + curated_urls = {x["source"] for x in curated} + fresh = [] if no_network else find_fresh(curated_urls) + template = (ROOT / "template.html").read_text() + now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M") + page = (template + .replace("__CURATED_COUNT__", str(len(curated))) + .replace("__FRESH_COUNT__", str(len(fresh))) + .replace("__UPDATED__", now) + .replace("__CURATED_CARDS__", "\n".join(curated_card(x) for x in curated)) + .replace("__FRESH_CARDS__", "\n".join(fresh_card(x) for x in fresh) if fresh else '

scout pass quiet

No machine-surfaced candidate cleared the threshold on this pass. That is a valid result.
') + ) + pathlib.Path(out_path).write_text(page) + print(json.dumps({"curated": len(curated), "fresh": len(fresh), "out": str(out_path)})) + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--out", default=str(ROOT / "index.html")) + p.add_argument("--no-network", action="store_true") + args = p.parse_args() + render(args.out, args.no_network)