Add deterministic Claude activity surface

This commit is contained in:
matcha committed 2026-10-07 04:04:23 +00:00
1 parent 03e6651f77
commit 52c8f466ea
10 files changed
+5522 -7

No files matched your search

+12
View File
@@ -4,6 +4,18 @@ on:
workflow_dispatch:
jobs:
acceptance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: verify generated Claude surface
run: |
test -s claude/index.html
python3 -m json.tool claude.json >/dev/null
grep -q 'claude.json' claude/index.html
grep -q 'COPY claude ' Dockerfile
- name: build published image
run: docker build --tag notplanned-acceptance .
note:
runs-on: ubuntu-latest
steps:
+2
View File
@@ -1,3 +1,5 @@
FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY claude /usr/share/nginx/html/claude
COPY claude.json /usr/share/nginx/html/claude.json
EXPOSE 80
+7 -1
View File
@@ -1,3 +1,9 @@
# notplanned
notplanned — we build the features maintainers won't
notplanned — we build the features maintainers won't
## deterministic surfaces
- `/claude/` is a bounded public GitHub commit scout. Refresh it with `python3 claude.py`; it writes `claude/index.html` and `claude.json`.
- The scan uses five fixed commit-search queries over a 30-day window, dedupes by repository + commit SHA, applies fixed keyword categories, and separates `anthropics/*` from independent repositories.
- GitHub Search is unauthenticated and rate-limited; each JSON snapshot records query results, rate-limit headers, and errors instead of hiding partial scans.
+24
View File
@@ -0,0 +1,24 @@
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>notplanned / claude</title>
<meta name="description" content="Deterministic GitHub scout for Claude-attributed coding activity.">
<style>
:root{color-scheme:light dark;--bg:#f3f1eb;--fg:#111;--muted:#6d6961;--line:#cbc7bc;--card:#faf8f2;--accent:#ff5a1f;--warm:#d99416}
@media(prefers-color-scheme:dark){:root{--bg:#10100f;--fg:#f2efe7;--muted:#a6a198;--line:#393732;--card:#171715;--accent:#ff7540;--warm:#efad32}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}a{color:inherit}.wrap{width:min(1180px,calc(100% - 36px));margin:auto}
header{min-height:76px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font-weight:900;letter-spacing:-.05em}.nav{font-size:11px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap;justify-content:flex-end}.nav a{text-decoration:none}.nav a:hover{color:var(--fg)}
.hero{padding:74px 0 45px;display:grid;grid-template-columns:1.3fr .7fr;gap:40px;align-items:end}h1{font-family:ui-sans-serif,system-ui,sans-serif;font-size:clamp(58px,10vw,112px);line-height:.85;letter-spacing:-.08em;margin:0}.dek{font-family:ui-sans-serif,system-ui,sans-serif;color:var(--muted);font-size:18px;line-height:1.45;margin:0}
.stats{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--line);border-radius:16px;overflow:hidden;margin-bottom:22px}.stat{padding:16px;border-right:1px solid var(--line)}.stat:last-child{border:0}.stat strong{display:block;font-family:ui-sans-serif,system-ui,sans-serif;font-size:22px}.stat span{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em}
.note{font-size:11.5px;color:var(--muted);line-height:1.5;margin:0 0 34px}.section-head{display:flex;justify-content:space-between;gap:24px;align-items:end;margin-bottom:17px}.section-head h2{font-family:ui-sans-serif,system-ui,sans-serif;font-size:30px;letter-spacing:-.04em;margin:0}.section-head p{margin:0;max-width:520px;text-align:right;font-size:12px;line-height:1.45;color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:58px}.commit-card{display:flex;flex-direction:column;min-height:260px;text-decoration:none;background:var(--card);border:1px solid var(--line);border-radius:17px;padding:19px;transition:.12s ease}.commit-card:hover{transform:translateY(-2px);border-color:var(--fg)}.topline{display:flex;justify-content:space-between;gap:12px;margin-bottom:18px}.repo,.meta,.why{font-size:10.5px;color:var(--muted)}.ecosystem{font-size:9px;text-transform:uppercase;letter-spacing:.08em;border:1px solid currentColor;border-radius:999px;padding:5px 7px}.ecosystem.independent{color:var(--accent)}.ecosystem.official{color:var(--warm)}h3{font-family:ui-sans-serif,system-ui,sans-serif;font-size:21px;line-height:1.08;letter-spacing:-.035em;margin:0 0 8px}.message{font-family:ui-sans-serif,system-ui,sans-serif;font-size:13px;line-height:1.45;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin:15px 0 auto;max-height:112px;overflow:hidden}.why{border-top:1px solid var(--line);padding-top:10px;margin-top:15px;line-height:1.45}.empty{border:1px dashed var(--line);padding:28px;border-radius:16px;color:var(--muted);grid-column:1/-1}
footer{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;color:var(--muted);font-size:11px}
@media(max-width:780px){.hero{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.stat:nth-child(2n){border-right:0}.stat:nth-child(-n+3){border-bottom:1px solid var(--line)}.section-head{align-items:start;flex-direction:column}.section-head p{text-align:left}}
</style></head><body><div class="wrap">
<header><div class="brand">notplanned / claude</div><div class="nav"><a href="/">scout</a><a href="/trending/">trending</a><a href="/claude.json">claude.json</a><a href="https://github.com/search?q=Claude&type=commits" target="_blank" rel="noreferrer">source ↗</a></div></header>
<section class="hero"><h1>claude signal.</h1><p class="dek">A bounded, deterministic mirror of public GitHub commits attributed to Claude, Claude Code, or Claude co-authorship. The independent ecosystem stays visible beside official Anthropic work.</p></section>
<section class="stats"><div class="stat"><strong>__COUNT__</strong><span>commits</span></div><div class="stat"><strong>__INDEPENDENT__</strong><span>independent</span></div><div class="stat"><strong>__OFFICIAL__</strong><span>official</span></div><div class="stat"><strong>__REPOS__</strong><span>repos</span></div><div class="stat"><strong>__QUERIES__</strong><span>bounded queries</span></div></section>
<p class="note">Updated __UPDATED__ UTC. Search window: last 30 days. Results are deduped by repository + commit SHA, sorted newest first, and categorized with fixed keyword rules. GitHub search can be incomplete when its public rate limit is low.</p>
<section><div class="section-head"><h2>independent ecosystem</h2><p>Public repositories outside the Anthropic organization: the clearest demand/discovery signal for what people are asking Claude to build.</p></div><div class="grid">__INDEPENDENT_CARDS__</div></section>
<section><div class="section-head"><h2>official Anthropic</h2><p>Anthropic-owned repositories are separated so first-party implementation work does not obscure independent activity.</p></div><div class="grid">__OFFICIAL_CARDS__</div></section>
<footer><span>notplanned · Claude activity scout</span><span>deterministic refresh · no per-scan model call</span></footer>
</div></body></html>
+3185
View File
File diff suppressed because it is too large. Load diff
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""Build a bounded, deterministic view of Claude-attributed GitHub commits."""
import argparse
import datetime as dt
import html
import json
import pathlib
import re
import urllib.error
import urllib.parse
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent
LOOKBACK_DAYS = 30
MAX_RESULTS_PER_QUERY = 50
SEARCHES = [
"author:claude[bot]",
"committer:claude[bot]",
"author:claude",
"committer:claude",
'"Co-authored-by: Claude"',
]
COAUTHOR_RE = re.compile(r"(?im)^\s*co-authored-by:\s*(.*?)\s*<([^>]+)>\s*$")
class GithubApiError(RuntimeError):
def __init__(self, status, detail, headers):
super().__init__(detail)
self.status = status
self.headers = headers
def api(url):
req = urllib.request.Request(url, headers={
"User-Agent": "notplanned-claude-scout/0.1",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
try:
with urllib.request.urlopen(req, timeout=20) as response:
return json.load(response), dict(response.headers)
except urllib.error.HTTPError as error:
try:
body = json.load(error)
detail = body.get("message", "GitHub API request failed")
except Exception:
detail = "GitHub API request failed"
raise GithubApiError(error.code, detail, dict(error.headers)) from error
def esc(value):
return html.escape(str(value), quote=True)
def parse_date(value):
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return dt.datetime.min.replace(tzinfo=dt.timezone.utc)
def age_days(value, now):
return max(0, (now - parse_date(value)).days)
def identity_text(identity):
if not identity:
return ""
return " ".join(str(identity.get(key) or "") for key in ("login", "name", "email")).lower()
def is_claude_identity(identity):
text = identity_text(identity)
return bool(re.search(r"(?:claude\s*\[bot\]|claude\s+code|\bclaude\b)", text))
def item_identity(item, role):
commit_identity = (item.get("commit") or {}).get(role) or {}
account = item.get(role) or {}
return {
"login": account.get("login") or "",
"name": commit_identity.get("name") or "",
"email": commit_identity.get("email") or "",
}
def coauthors(message):
return [
{"name": name.strip(), "email": email.strip()}
for name, email in COAUTHOR_RE.findall(message or "")
if is_claude_identity({"name": name, "email": email})
]
def category(message):
text = (message or "").lower()
buckets = [
("browser", ["browser", "playwright", "puppeteer", "chrome", "screenshot"]),
("integration", ["mcp", "api", "webhook", "integration", "plugin", "connector"]),
("workflow", ["workflow", "task", "agent", "command", "hook", "automation"]),
("context", ["context", "memory", "session", "history", "resume", "checkpoint"]),
("interface", ["ui", "ux", "sidebar", "dashboard", "panel", "menu"]),
("testing", ["test", "spec", "fixture", "assert", "coverage"]),
("infra", ["ci", "build", "deploy", "docker", "release", "dependency"]),
("docs", ["readme", "docs", "documentation", "changelog"]),
]
for name, words in buckets:
if any(word in text for word in words):
return name
return "implementation"
def attribution_kind(author_match, committer_match, coauthor_match):
kinds = []
if author_match:
kinds.append("author")
if committer_match:
kinds.append("committer")
if coauthor_match:
kinds.append("co-author")
return " + ".join(kinds)
def candidate(item, query):
commit = item.get("commit") or {}
message = commit.get("message") or ""
author = item_identity(item, "author")
committer = item_identity(item, "committer")
claude_coauthors = coauthors(message)
author_match = is_claude_identity(author)
committer_match = is_claude_identity(committer)
coauthor_match = bool(claude_coauthors)
if not (author_match or committer_match or coauthor_match):
return None
repository = item.get("repository") or {}
repo = repository.get("full_name") or ""
if not repo:
return None
timestamp = (committer.get("date") or author.get("date") or "")
# GitHub returns dates inside commit.author/commit.committer, not the flattened identities.
timestamp = (commit.get("committer") or {}).get("date") or (commit.get("author") or {}).get("date") or timestamp
official = repo.lower().startswith("anthropics/")
return {
"repo": repo,
"repo_url": repository.get("html_url") or f"https://github.com/{repo}",
"commit_sha": item.get("sha") or "",
"commit_url": item.get("html_url") or "",
"commit_message": message[:4000],
"subject": (message.splitlines() or ["(no commit message)"])[0][:240],
"author": author,
"committer": committer,
"timestamp": timestamp,
"attribution_kind": attribution_kind(author_match, committer_match, coauthor_match),
"coauthors": claude_coauthors,
"category": category(message),
"ecosystem": "official Anthropic" if official else "independent",
"official_anthropic": official,
"matched_queries": [query],
"_author_match": author_match,
"_committer_match": committer_match,
"_coauthor_match": coauthor_match,
}
def merge(existing, incoming):
for field in ("_author_match", "_committer_match", "_coauthor_match"):
existing[field] = existing[field] or incoming[field]
for query in incoming["matched_queries"]:
if query not in existing["matched_queries"]:
existing["matched_queries"].append(query)
if not existing["coauthors"] and incoming["coauthors"]:
existing["coauthors"] = incoming["coauthors"]
existing["attribution_kind"] = attribution_kind(
existing["_author_match"], existing["_committer_match"], existing["_coauthor_match"]
)
def rate_snapshot(headers):
if not headers:
return {}
return {
key: headers.get(value)
for key, value in (
("limit", "X-RateLimit-Limit"),
("remaining", "X-RateLimit-Remaining"),
("reset", "X-RateLimit-Reset"),
)
if headers.get(value) is not None
}
def build():
now = dt.datetime.now(dt.timezone.utc)
cutoff = (now - dt.timedelta(days=LOOKBACK_DAYS)).date().isoformat()
rows = {}
errors = []
query_log = []
last_rate = {}
filtered_future = 0
for term in SEARCHES:
today = now.date().isoformat()
query = f"{term} committer-date:>={cutoff} committer-date:<={today}"
url = "https://api.github.com/search/commits?" + urllib.parse.urlencode({
"q": query,
"sort": "committer-date",
"order": "desc",
"per_page": MAX_RESULTS_PER_QUERY,
})
try:
payload, headers = api(url)
last_rate = rate_snapshot(headers) or last_rate
items = payload.get("items", [])
query_log.append({"term": term, "query": query, "returned": len(items), "total_count": payload.get("total_count", 0)})
except GithubApiError as error:
last_rate = rate_snapshot(error.headers) or last_rate
query_log.append({"term": term, "query": query, "returned": 0, "error": str(error)})
errors.append({"term": term, "status": error.status, "error": str(error)})
continue
except Exception as error:
query_log.append({"term": term, "query": query, "returned": 0, "error": type(error).__name__})
errors.append({"term": term, "error": type(error).__name__})
continue
for item in items:
row = candidate(item, term)
if not row or not row["commit_sha"]:
continue
if parse_date(row["timestamp"]) > now:
filtered_future += 1
continue
key = (row["repo"].lower(), row["commit_sha"])
if key in rows:
merge(rows[key], row)
else:
rows[key] = row
candidates = list(rows.values())
candidates.sort(key=lambda row: (
1 if not row["official_anthropic"] else 0,
row["timestamp"],
row["repo"].lower(),
row["commit_sha"],
), reverse=True)
for row in candidates:
for field in ("_author_match", "_committer_match", "_coauthor_match"):
del row[field]
return {
"generated_at": now.isoformat(),
"source": "https://api.github.com/search/commits",
"rules": {
"lookback_days": LOOKBACK_DAYS,
"max_results_per_query": MAX_RESULTS_PER_QUERY,
"dedupe_key": "repo + commit_sha",
"searches": SEARCHES,
"official_definition": "repo full_name starts with anthropics/",
},
"api": {"rate_limit": last_rate, "queries": query_log},
"filters": {"future_timestamps_excluded": filtered_future},
"candidates": candidates,
"errors": errors,
}
def identity_label(identity):
return identity.get("login") or identity.get("name") or identity.get("email") or "unknown"
def card(row):
authors = f"author {identity_label(row['author'])} · committer {identity_label(row['committer'])}"
if row["coauthors"]:
authors += " · co-author " + ", ".join(x["name"] for x in row["coauthors"])
return f"""<a class=\"commit-card\" href=\"{esc(row['commit_url'])}\" target=\"_blank\" rel=\"noreferrer\">
<div class=\"topline\"><span class=\"repo\">{esc(row['repo'])}</span><span class=\"ecosystem {'official' if row['official_anthropic'] else 'independent'}\">{esc(row['ecosystem'])}</span></div>
<h3>{esc(row['subject'])}</h3>
<div class=\"meta\">{esc(row['category'])} · {esc(row['attribution_kind'])} · {esc(row['timestamp'])}</div>
<p class=\"message\">{esc(row['commit_message'])}</p>
<div class=\"why\">{esc(authors)}</div>
</a>"""
def render(out_html, out_json):
data = build()
independent = [row for row in data["candidates"] if not row["official_anthropic"]]
official = [row for row in data["candidates"] if row["official_anthropic"]]
template = (ROOT / "claude-template.html").read_text()
updated = data["generated_at"][:16].replace("T", " ")
page = (template
.replace("__UPDATED__", esc(updated))
.replace("__COUNT__", str(len(data["candidates"])))
.replace("__INDEPENDENT__", str(len(independent)))
.replace("__OFFICIAL__", str(len(official)))
.replace("__REPOS__", str(len({row["repo"] for row in data["candidates"]})))
.replace("__QUERIES__", str(len(data["api"]["queries"])))
.replace("__INDEPENDENT_CARDS__", "\n".join(card(row) for row in independent) or '<div class="empty">No independent Claude-attributed commits cleared the bounded search window.</div>')
.replace("__OFFICIAL_CARDS__", "\n".join(card(row) for row in official) or '<div class="empty">No official Anthropic commits matched this pass.</div>')
)
out_html = pathlib.Path(out_html)
out_html.parent.mkdir(parents=True, exist_ok=True)
out_html.write_text(page)
pathlib.Path(out_json).write_text(json.dumps(data, indent=2) + "\n")
print(json.dumps({
"candidates": len(data["candidates"]),
"independent": len(independent),
"official": len(official),
"repos": len({row["repo"] for row in data["candidates"]}),
"queries": len(data["api"]["queries"]),
"errors": len(data["errors"]),
"out": str(out_html),
}))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--html-out", default=str(ROOT / "claude/index.html"))
parser.add_argument("--json-out", default=str(ROOT / "claude.json"))
args = parser.parse_args()
render(args.html_out, args.json_out)
+1966
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -49,7 +49,7 @@ footer{padding:0 0 44px;display:flex;justify-content:space-between;gap:24px;colo
<div class="wrap">
<header>
<div class="brand">notplanned</div>
<div class="tiny">a yolopreneur game</div>
<div class="tiny"><a href="/claude/">claude signal</a> · a yolopreneur game</div>
</header>
<main>
@@ -92,4 +92,4 @@ footer{padding:0 0 44px;display:flex;justify-content:space-between;gap:24px;colo
</footer>
</div>
</body>
</html>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@ footer{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify
<body><div class="wrap">
<header>
<div class="brand">notplanned</div>
<div class="nav"><a href="/trending/">trending</a><a href="#curated">curated</a><a href="#radar">raw radar</a><a href="/trending.json">trending.json</a><a href="/radar.json">radar.json</a><a href="/curated.json">curated.json</a></div>
<div class="nav"><a href="/claude/">claude</a><a href="/trending/">trending</a><a href="#curated">curated</a><a href="#radar">raw radar</a><a href="/claude.json">claude.json</a><a href="/trending.json">trending.json</a><a href="/radar.json">radar.json</a><a href="/curated.json">curated.json</a></div>
</header>
<section class="hero">
@@ -60,4 +60,4 @@ footer{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify
</section>
<footer><span>notplanned · GitHub scout</span><span>deterministic radar refreshes every 6h · curation only when asked</span></footer>
</div></body></html>
</div></body></html>
+2 -2
View File
@@ -13,7 +13,7 @@ header{min-height:76px;border-bottom:1px solid var(--line);display:flex;align-it
footer{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;color:var(--muted);font-size:11px}
@media(max-width:780px){.hero{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.stat:nth-child(2){border-right:0}.stat:nth-child(-n+2){border-bottom:1px solid var(--line)}}
</style></head><body><div class="wrap">
<header><div class="brand">notplanned / trending</div><div class="nav"><a href="/">scout</a><a href="/trending.json">trending.json</a><a href="https://github.com/trending" target="_blank">source ↗</a></div></header>
<header><div class="brand">notplanned / trending</div><div class="nav"><a href="/">scout</a><a href="/claude/">claude</a><a href="/trending.json">trending.json</a><a href="https://github.com/trending" target="_blank">source ↗</a></div></header>
<section class="hero"><h1>what's hot.</h1><p class="dek">A deterministic mirror of GitHub Trending. Daily, weekly, and monthly lists are fetched directly from GitHub, merged by repository, and left uninterpreted.</p></section>
<section class="stats">
<div class="stat"><strong>__DAILY__</strong><span>daily</span></div>
@@ -24,4 +24,4 @@ footer{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify
<p class="note">Updated __UPDATED__ UTC. Badges preserve GitHub's rank for each time window and, when GitHub exposes it, stars gained in that window. “2/3” or “3/3” means the repo appears across multiple Trending windows; it is not an AI score.</p>
<section class="grid">__CARDS__</section>
<footer><span>notplanned · trending feed</span><span>source: github.com/trending</span></footer>
</div></body></html>
</div></body></html>