From ab45618c1558d4b7f5751dab048f3df547eccc69 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 15 Mar 2026 09:24:28 +0100 Subject: [PATCH] chore(wiki): add find-stubs script for batch planning Replaces manual Explore agent searches with a fast Python script that finds unwritten wiki stubs by sector and hop range. Reads star-map.json for topology and wiki stubs for star type/sector/wave. Co-Authored-By: Claude Opus 4.6 --- tooling/wiki/find-stubs | 158 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100755 tooling/wiki/find-stubs diff --git a/tooling/wiki/find-stubs b/tooling/wiki/find-stubs new file mode 100755 index 000000000..617917aa2 --- /dev/null +++ b/tooling/wiki/find-stubs @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +find-stubs — Find unwritten wiki system stubs by sector and hop range. + +Usage: + tooling/wiki/find-stubs west_reach 7 9 + tooling/wiki/find-stubs south_reach 3 5 --limit 10 + tooling/wiki/find-stubs --all-sectors 6 8 + +Outputs a markdown table with all data needed for batch planning. +A page is "written" if it has >36 lines. + +Data sources: +- star-map.json: topology (adjacency, hop distance, apertures, gate_topology) +- wiki stubs: star type, sector, settlement wave (from READ-ONLY System Profile) +""" + +import argparse +import json +import re +from pathlib import Path + +STAR_MAP = Path(__file__).parent.parent.parent / "docs/design/star-map.json" +WIKI_DIR = Path(__file__).parent.parent.parent / "wiki/star-systems" +STUB_MAX_LINES = 36 + + +def sid_to_dir(system_id: str) -> str: + """Convert system_id like 'GJ 103' to directory name 'GJ-103'.""" + return system_id.replace(" ", "-") + + +def read_wiki(system_id: str) -> dict | None: + """Read star type, sector, wave, and name from a wiki page.""" + f = WIKI_DIR / sid_to_dir(system_id) / "index.md" + if not f.exists(): + return None + lines = f.read_text().splitlines() + info = {"lines": len(lines), "written": len(lines) > STUB_MAX_LINES} + + # Line 1: # Name + if lines and lines[0].startswith("# "): + info["name"] = lines[0][2:].strip() + + # Line 2: **GJ ID** | star-type | sector + if len(lines) > 1: + parts = lines[1].split("|") + if len(parts) >= 3: + info["star"] = parts[1].strip() + info["sector"] = parts[2].strip() + + # Find settlement wave in System Profile table + for line in lines: + if "wave_" in line or "unsettled" in line: + m = re.search(r"(wave_\d|unsettled)", line) + if m: + info["wave"] = m.group(1) + break + + return info + + +def main(): + parser = argparse.ArgumentParser(description="Find unwritten wiki stubs") + parser.add_argument("sector", nargs="?", help="Sector (e.g. west_reach)") + parser.add_argument("hop_min", nargs="?", type=int, default=0) + parser.add_argument("hop_max", nargs="?", type=int, default=99) + parser.add_argument("--all-sectors", action="store_true") + parser.add_argument("--limit", type=int, default=0) + parser.add_argument("--written", action="store_true", + help="Show written pages instead of stubs") + args = parser.parse_args() + + if args.all_sectors: + if args.sector and args.sector.isdigit(): + args.hop_min = int(args.sector) + if args.hop_min and args.hop_max == 99: + pass # keep default + args.sector = None + + with open(STAR_MAP) as f: + data = json.load(f) + + # Build node lookup and adjacency from star-map.json + nodes = {n["system_id"]: n for n in data["nodes"]} + adj: dict[str, list[str]] = {n["system_id"]: [] for n in data["nodes"]} + for a, b in data["edges"]: + adj[a].append(b) + adj[b].append(a) + + results = [] + for sid, node in nodes.items(): + hop = node.get("hop_distance_from_gateway", 99) + if hop < args.hop_min or hop > args.hop_max: + continue + + wiki = read_wiki(sid) + if not wiki: + continue + + # Filter by sector + sector = wiki.get("sector", "") + if args.sector and sector != args.sector: + continue + + # Filter by written status + if args.written and not wiki["written"]: + continue + if not args.written and wiki["written"]: + continue + + # Build neighbor info + neighbors = [] + for n_sid in sorted(adj.get(sid, [])): + n_wiki = read_wiki(n_sid) + if n_wiki and n_wiki["written"]: + neighbors.append(f"✓{sid_to_dir(n_sid)} ({n_wiki.get('name', '?')})") + else: + neighbors.append(f"·{sid_to_dir(n_sid)}") + + results.append({ + "hop": hop, + "sid": sid, + "star": wiki.get("star", "?"), + "apertures": node.get("aperture_count", 0), + "topology": node.get("gate_topology", "?"), + "wave": wiki.get("wave", "?"), + "neighbors": neighbors, + }) + + results.sort(key=lambda r: (r["hop"], r["sid"])) + if args.limit: + results = results[:args.limit] + + # Output + sector_label = args.sector or "all sectors" + mode = "written" if args.written else "stubs" + print(f"## {mode.title()}: {sector_label} hops {args.hop_min}-{args.hop_max}") + print(f"**Found {len(results)} systems**\n") + + print("| Hop | System | Star | Apt | Topology | Wave | Adjacent |") + print("|-----|--------|------|-----|----------|------|----------|") + for r in results: + adj_str = ", ".join(r["neighbors"]) + print(f"| {r['hop']} | {r['sid']} | {r['star']} | {r['apertures']} " + f"| {r['topology']} | {r['wave']} | {adj_str} |") + + # Summary stats + if results: + by_hop = {} + for r in results: + by_hop.setdefault(r["hop"], 0) + by_hop[r["hop"]] += 1 + print(f"\n**By hop:** " + ", ".join(f"hop {h}: {c}" for h, c in sorted(by_hop.items()))) + + +if __name__ == "__main__": + main()