diff --git a/scout.py b/scout.py
index 02ce031..9b2ce66 100644
--- a/scout.py
+++ b/scout.py
@@ -1,150 +1,163 @@
#!/usr/bin/env python3
-import argparse
-import datetime as dt
-import html
-import json
-import math
-import pathlib
-import urllib.request
-import urllib.error
+import argparse, datetime as dt, html, json, math, pathlib, urllib.request, 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",
+ "User-Agent": "notplanned-radar/0.2",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
- with urllib.request.urlopen(req, timeout=15) as r:
+ with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
-def score_issue(issue, now):
- text = (issue.get("title","") + " " + (issue.get("body") or "")).lower()
+def esc(v): return html.escape(str(v), quote=True)
+
+def repo_heat(repo, now):
+ pushed = repo.get("pushed_at") or repo.get("updated_at")
+ try:
+ age = max(0, (now - dt.datetime.fromisoformat(pushed.replace("Z","+00:00"))).days)
+ except Exception:
+ age = 999
+ activity = 15 if age <= 7 else 10 if age <= 30 else 5 if age <= 90 else 0
+ stars = int(repo.get("stargazers_count") or 0)
+ star_score = min(15, round(math.log10(max(stars, 1)) * 3.2))
+ return activity + star_score, {"repo_activity": activity, "repo_scale": star_score}
+
+def issue_score(issue, repo_meta, cfg, now):
+ labels = {str(x.get("name","")).strip().lower() for x in issue.get("labels", [])}
+ if labels & {x.lower() for x in cfg["exclude_labels"]}:
+ return None
+ rejection = 0
+ reason = issue.get("state_reason")
+ if reason == "not_planned":
+ rejection = max(rejection, 32)
+ for label, pts in cfg["rejection_labels"].items():
+ if label.lower() in labels:
+ rejection = max(rejection, pts)
+ if rejection == 0:
+ return None
+
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)
+ age = max(0, (now - dt.datetime.fromisoformat(closed.replace("Z","+00:00"))).days)
except Exception:
- age = 365
- recency = max(0, 90 - age)
- comments = min(24, int(issue.get("comments") or 0) * 2)
+ age = 999
+ recency = 40 if age <= 7 else 30 if age <= 30 else 16 if age <= 90 else 6 if age <= 180 else 0
+
+ comments = min(20, 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)
+ reaction_score = min(20, int(reactions.get("total_count") or 0) * 2)
-def suggested_move(title):
+ title = (issue.get("title") or "").lower()
+ body = (issue.get("body") or "").lower()
+ text = title + "\n" + body[:4000]
+ ext = 0
+ matched = []
+ for word, pts in cfg["externalizable_keywords"].items():
+ if word.lower() in text:
+ ext += pts
+ matched.append(word)
+ ext = min(28, ext)
+
+ heat, heat_breakdown = repo_heat(repo_meta, now)
+ breakdown = {
+ "rejection": rejection,
+ "recency": recency,
+ "engagement": comments + reaction_score,
+ "externalizable": ext,
+ **heat_breakdown,
+ }
+ score = sum(breakdown.values())
+ return {
+ "score": score,
+ "breakdown": breakdown,
+ "age_days": age,
+ "matched": matched[:6],
+ }
+
+def category(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."
+ buckets = [
+ ("browser", ["browser","upload","preview","localhost","tab","form"]),
+ ("context", ["context","compact","session","memory","history"]),
+ ("authority", ["permission","approval","sandbox","security","guardrail"]),
+ ("integration", ["mcp","api","webhook","integration","plugin","extension"]),
+ ("interface", ["ui","sidebar","dashboard","preset","menu","export"]),
+ ("workflow", ["task","project","workflow","plan","goal"]),
+ ]
+ for name, words in buckets:
+ if any(w in t for w in words): return name
+ return "wrapper"
-def esc(s):
- return html.escape(str(s), quote=True)
+def move_for(cat):
+ return {
+ "browser":"external browser sidecar / extension",
+ "context":"session/context sidecar",
+ "authority":"policy proxy / scoped authority layer",
+ "integration":"adapter / MCP / integration bridge",
+ "interface":"thin UI / control surface",
+ "workflow":"supervisor / workflow wrapper",
+ "wrapper":"thin wrapper around existing capability",
+ }[cat]
-def curated_card(o):
- return f"""
- {esc(o['thesis'])} {esc(o['move'])}{esc(o['name'])}
- {esc(o['title'])}
+
+ {esc(o['title'])}
-
-