$ cat node-template.py

C

Codebase map

// Read a GitHub repository at a branch and map it against a request or a plan: which conventions apply, what already exists for each part of it (with paths), what does not exist yet, and where it touches shared code — plus facts written by code: the branch's commits beyond its base, the pull requests open into it, and the checks the repository can run. Reads the convention files and the files a model picks; a small repository is read whole. Wire its map into every Plan review so their findings are grounded in real code.

Process
LLM
#github#codebase#context#review#vibe
template.py
1"""Codebase map — what already exists, read from the repository itself.23gstack's reviews run INSIDE the repository. Their first question is the eng4review's scope challenge — "what existing code already solves each5sub-problem" — and office-hours asks the same of every premise. A Plan review6node sees only the design doc's text, so without this node its "already7exists" and every file path it names are guesses. This node is where they8stop being guesses.910It reads the repository ONCE, at the plan's working branch, and every review11reads its map. One grounded retrieval instead of five that would disagree, and12a readable artefact on the canvas that shows exactly what the reviews knew.1314## How it chooses what to read1516It cannot read a monorepo, and it does not try:1718  1. the tree at the branch (one API call — paths only);19  2. a model turns the plan into search terms;20  3. top-level structure + the paths matching those terms become a shortlist;21  4. a model picks up to `maxFiles` from the shortlist — picks outside the22     tree are dropped, so it cannot "read" a file that does not exist;23  5. those files and the convention files (CLAUDE.md, AGENTS.md, README...)24     are read, each and in total size-capped;25  6. a model writes the map, citing only paths it read or saw in the tree.2627GitHub is reached only through `gais.connectors.github` — the sandbox blocks28network imports, and the connector keeps the token out of every error.2930## It runs twice, and it states facts (2026-09-29)3132gstack's /office-hours opens with "Context Gathering" — CLAUDE.md, `git log`,33`git diff --stat`, a grep of the request's areas — BEFORE its first question.34So the map now runs first at step 4, right after the Working branch, against35the user's REQUEST (the design doc holds just the request until it is36written), and again in Phase 4 against the written doc: the doc's edge makes37it out of date. A short-lived "Repo probe" node did the first read; it was38folded back in here, because the map was the better read.3940Beside the model's map it states FACTS, written by code, never paraphrased by41a model — a check command or a PR number is either there or it is not:42  - the repository: size, languages, top level, convention files, empty or not;43  - the branch: commits beyond its base, pull requests open into and out of it;44  - the checks: package.json scripts (with the lockfile's package manager),45    Makefile targets, check.sh, CI workflows.46The agent reads `facts` for the branch-state question and the approval gate's47checks. A repository with a dozen files or fewer is read whole, with one call.48"""4950import asyncio51import json52import posixpath53import re54import sys55import traceback56from typing import Any5758from gais import Gais, connectors59from gais import github_repo as gh60from gais._errors import ConfigError, RemoteError6162#: Files that state a repository's rules. Read first and always when present:63#: a plan that breaks a convention written in CLAUDE.md is a finding every64#: review should be able to make.65CONVENTION_NAMES = ("CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md", "README.md", "DESIGN.md")6667#: Never worth a slot on the shortlist.68NOISE = re.compile(69    r"(^|/)(node_modules|dist|build|\.next|coverage|vendor|__pycache__|\.git|\.venv)(/|$)"70    r"|\.(png|jpe?g|gif|svg|ico|webp|pdf|zip|gz|woff2?|ttf|mp[34]|lock|min\.js|map)$"71    r"|(^|/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|uv\.lock|poetry\.lock)$",72    re.IGNORECASE,73)7475SHORTLIST_LIMIT = 40076STRUCTURE_DEPTH = 277FILE_CHARS = 8_00078TOTAL_CHARS = 120_00079NESTED_CONVENTIONS = 480SMALL_REPO = 12          # content files: read them all, no search step81TOP_LEVEL_LIMIT = 4082COMMITS_SHOWN = 108384#: Root files that say what a repository can build, test and run.85MANIFEST_NAMES = ("package.json", "Makefile", "pyproject.toml", "requirements.txt", "go.mod",86                  "Cargo.toml", "Gemfile", "composer.json", "Dockerfile", "docker-compose.yml",87                  "docker-compose.yaml", "check.sh")8889#: Files that do not make a repository "have code": one holding only these is90#: empty for planning purposes.91BOILERPLATE = re.compile(92    r"^(README|LICENSE|LICENCE|CHANGELOG|CODEOWNERS|CONTRIBUTING|SECURITY)(\.[\w]+)?$"93    r"|^\.(gitignore|gitattributes|editorconfig|nvmrc|node-version)$|^\.github/",94    re.IGNORECASE,95)9697LANGUAGES = {98    ".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript",99    ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript", ".html": "HTML",100    ".css": "CSS", ".scss": "CSS", ".go": "Go", ".rs": "Rust", ".rb": "Ruby", ".java": "Java",101    ".kt": "Kotlin", ".php": "PHP", ".cs": "C#", ".swift": "Swift", ".vue": "Vue",102    ".svelte": "Svelte", ".sql": "SQL", ".sh": "Shell",103}104105106def is_noise(path: str) -> bool:107    return bool(NOISE.search(path))108109110#: A branch name as one path segment — `vibe/x` must not become two. Shared111#: with the rest of the repository node set (`gais.github_repo`).112enc = gh.enc113114115# ── Facts: read, never inferred ─────────────────────────────────────────────116117def is_boilerplate(path: str) -> bool:118    return bool(BOILERPLATE.search(path))119120121def languages(blobs: list[str]) -> dict[str, int]:122    counts: dict[str, int] = {}123    for p in blobs:124        name = None if is_noise(p) else LANGUAGES.get(posixpath.splitext(p)[1].lower())125        if name:126            counts[name] = counts.get(name, 0) + 1127    return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))128129130def top_level(blobs: list[str]) -> list[str]:131    seen: list[str] = []132    for p in sorted(blobs):133        entry = p.split("/", 1)[0] + ("/" if "/" in p else "")134        if entry not in seen and not is_noise(entry):135            seen.append(entry)136    return seen[:TOP_LEVEL_LIMIT]137138139def package_manager(blobs: list[str]) -> str:140    names = set(blobs)141    if "pnpm-lock.yaml" in names:142        return "pnpm"143    if "yarn.lock" in names:144        return "yarn"145    if "bun.lockb" in names or "bun.lock" in names:146        return "bun"147    return "npm"148149150def package_scripts(text: str, manager: str) -> list[dict]:151    try:152        scripts = (json.loads(text) or {}).get("scripts") or {}153    except (json.JSONDecodeError, ValueError, AttributeError):154        return []155    run = "yarn" if manager == "yarn" else f"{manager} run"156    return [{"source": "package.json", "name": name, "command": f"{run} {name}"}157            for name in scripts if isinstance(name, str)]158159160MAKE_TARGET = re.compile(r"^([A-Za-z0-9][\w.-]*)\s*:(?!=)", re.MULTILINE)161162163def make_targets(text: str) -> list[dict]:164    names: list[str] = []165    for name in MAKE_TARGET.findall(text or ""):166        if name not in names:167            names.append(name)168    return [{"source": "Makefile", "name": n, "command": f"make {n}"} for n in names]169170171def headline(commit: Any) -> str:172    return (((commit or {}).get("commit") or {}).get("message") or "").split("\n", 1)[0] \173        if isinstance(commit, dict) else ""174175176def branch_state(owner: str, repo: str, base: str, branch: str, get) -> dict:177    """What the branch holds beyond its base, and what is open against it.178179    Never fatal: a map without branch facts is still a map.180    """181    path = f"/repos/{owner}/{repo}"182    state: dict[str, Any] = {"branch": branch, "base": base}183    if base and base != branch:184        try:185            cmp = get(f"{path}/compare/{enc(base)}...{enc(branch)}")186        except RemoteError as exc:187            cmp = None188            state["compare_error"] = (f"Could not compare {branch} with {base} "189                                      f"(HTTP {getattr(exc, 'http_status', '?')}).")190        if isinstance(cmp, dict):191            commits = cmp.get("commits") or []192            state.update({193                "ahead_by": cmp.get("ahead_by", len(commits)),194                "behind_by": cmp.get("behind_by", 0),195                "commits": [headline(c) for c in commits][-COMMITS_SHOWN:],196                "changed_files": [f.get("filename") for f in (cmp.get("files") or [])197                                  if isinstance(f, dict)][:50],198            })199200    def prs(query: str) -> list[dict]:201        try:202            rows = get(f"{path}/pulls?state=open&per_page=20&{query}")203        except RemoteError:204            return []205        return [{"number": r.get("number"), "title": r.get("title") or "",206                 "head": (r.get("head") or {}).get("ref"), "base": (r.get("base") or {}).get("ref"),207                 "draft": bool(r.get("draft")), "url": r.get("html_url") or ""}208                for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []209210    state["open_prs_into"] = prs(f"base={enc(branch)}")211    state["open_prs_from"] = prs(f"head={owner}:{enc(branch)}")212    try:213        recent = get(f"{path}/commits?sha={enc(branch)}&per_page=5")214    except RemoteError:215        recent = []216    state["recent_commits"] = [headline(c) for c in recent] if isinstance(recent, list) else []217    return state218219220def repo_facts(owner: str, repo: str, ref: str, base: str, blobs: list[str], truncated: bool,221               get, read) -> dict:222    """Everything the map states without a model."""223    if not base:224        try:225            info = get(f"/repos/{owner}/{repo}")226            base = info.get("default_branch") or "" if isinstance(info, dict) else ""227        except RemoteError:228            base = ""229    manifests = [p for p in blobs if p in MANIFEST_NAMES]230    scripts: list[dict] = []231    for name, parse in (("package.json", lambda t: package_scripts(t, package_manager(blobs))),232                        ("Makefile", make_targets)):233        if name in manifests:234            try:235                text = read(f"/repos/{owner}/{repo}/contents/{name}?ref={enc(ref)}")236            except RemoteError:237                continue238            scripts += parse(text if isinstance(text, str) else "")239    if "check.sh" in manifests:240        scripts.append({"source": "check.sh", "name": "check.sh", "command": "bash check.sh"})241    return {242        "ref": ref,243        "base": base,244        "empty": not any(not is_boilerplate(p) and not is_noise(p) for p in blobs),245        "file_count": len(blobs),246        "truncated": truncated,247        "languages": languages(blobs),248        "top_level": top_level(blobs),249        "conventions": [p for p in blobs if posixpath.basename(p) in CONVENTION_NAMES250                        and p.count("/") <= 2][:10],251        "manifests": manifests,252        "scripts": scripts,253        "workflows": sorted(p for p in blobs if p.startswith(".github/workflows/")),254        "branch": branch_state(owner, repo, base, ref, get),255    }256257258def render_facts(facts: dict) -> str:259    """The facts as markdown — code, not a model, writes these three sections."""260    langs = ", ".join(f"{k} {v}" for k, v in facts["languages"].items()) or "none recognised"261    lines = ["## Repository", f"- {facts['file_count']} files at `{facts['ref']}`; languages: {langs}."]262    if facts["empty"]:263        lines.append("- It holds no code yet — only "264                     + (", ".join(f"`{p}`" for p in facts["top_level"]) or "nothing") + ".")265    else:266        lines.append("- Top level: " + ", ".join(f"`{p}`" for p in facts["top_level"]) + ".")267    if facts["conventions"]:268        lines.append("- Convention files: " + ", ".join(f"`{p}`" for p in facts["conventions"]) + ".")269270    b = facts["branch"]271    lines += ["", "## Branch"]272    if b.get("compare_error"):273        lines.append(f"- {b['compare_error']}")274    elif b.get("base") and b.get("base") != b.get("branch"):275        ahead = b.get("ahead_by") or 0276        if ahead:277            lines.append(f"- `{b['branch']}` is {ahead} commit{'s' if ahead != 1 else ''} ahead of "278                         f"`{b['base']}` ({b.get('behind_by', 0)} behind): "279                         + "; ".join(b.get("commits") or []) + ".")280            if b.get("changed_files"):281                lines.append("- Changed beyond the base: "282                             + ", ".join(f"`{p}`" for p in b["changed_files"]) + ".")283        else:284            lines.append(f"- `{b['branch']}` holds nothing beyond `{b['base']}`.")285    for label, key in (("Open pull requests INTO it", "open_prs_into"),286                       ("Open pull requests FROM it", "open_prs_from")):287        rows = b.get(key) or []288        if rows:289            lines.append(f"- {label}: " + "; ".join(290                f"#{r['number']} {r['title']} (`{r['head']}` → `{r['base']}`{', draft' if r['draft'] else ''})"291                for r in rows) + ".")292    if b.get("recent_commits"):293        lines.append("- Latest commits: " + "; ".join(b["recent_commits"]) + ".")294295    lines += ["", "## Checks the repository has"]296    if facts["scripts"] or facts["workflows"]:297        lines += [f"- `{x['command']}` ({x['source']})" for x in facts["scripts"]]298        lines += [f"- CI: `{p}`" for p in facts["workflows"]]299    else:300        lines.append("- None: no package.json scripts, no Makefile, no check script, no CI workflow.")301    if facts["truncated"]:302        lines += ["", "NOTE: the repository is larger than GitHub lists in one call; "303                      "paths outside the listing were not considered."]304    return "\n".join(lines)305306307def conventions_in(blobs: list[str], picked_dirs: set[str]) -> list[str]:308    """Root convention files, plus CLAUDE.md / AGENTS.md in the directories309    the picked files live in — this repo keeps per-app rules next to the app."""310    root = [p for p in blobs if p in CONVENTION_NAMES]311    nested = sorted(312        p for p in blobs313        if "/" in p and p.rsplit("/", 1)[1] in ("CLAUDE.md", "AGENTS.md")314        and any(d == p.rsplit("/", 1)[0] or d.startswith(p.rsplit("/", 1)[0] + "/") for d in picked_dirs)315    )316    return sorted(root, key=CONVENTION_NAMES.index) + nested[:NESTED_CONVENTIONS]317318319def structure(blobs: list[str], depth: int = STRUCTURE_DEPTH) -> list[str]:320    """Directories down to `depth` — the shape of the repository, for the picker."""321    dirs = set()322    for p in blobs:323        parts = p.split("/")[:-1]324        for i in range(1, min(len(parts), depth) + 1):325            dirs.add("/".join(parts[:i]) + "/")326    return sorted(dirs)327328329def shortlist(blobs: list[str], terms: list[str], limit: int = SHORTLIST_LIMIT) -> list[str]:330    """Paths matching any search term, most matches first, then shallowest.331332    Matching is on the path, lower-cased: a term like `share` finds333    `share-link-dialog.tsx` and `lib/share-links/`. Crude, and deliberately so334    — the model picks from this list, so it only has to contain the right335    files, not rank them perfectly.336    """337    terms = [t.lower() for t in terms if isinstance(t, str) and len(t.strip()) >= 3]338    scored = []339    for p in blobs:340        if is_noise(p):341            continue342        low = p.lower()343        hits = sum(1 for t in terms if t in low)344        if hits:345            scored.append((-hits, p.count("/"), p))346    return [p for _, _, p in sorted(scored)[:limit]]347348349def keep_real(picks: Any, blobs: set[str], limit: int) -> list[str]:350    """The model's picks that are real files, in order, de-duplicated."""351    out = []352    for p in picks if isinstance(picks, list) else []:353        if isinstance(p, str) and p in blobs and p not in out and not is_noise(p):354            out.append(p)355        if len(out) >= limit:356            break357    return out358359360def parse_json(raw: str) -> dict:361    text = raw.strip()362    if text.startswith("```"):363        text = text.split("\n", 1)[-1].rstrip().removesuffix("```")364    try:365        data = json.loads(text)366        return data if isinstance(data, dict) else {}367    except (json.JSONDecodeError, ValueError):368        match = re.search(r"\{.*\}", text, re.DOTALL)369        if match:370            try:371                data = json.loads(match.group(0))372                return data if isinstance(data, dict) else {}373            except (json.JSONDecodeError, ValueError):374                pass375    return {}376377378def read_files(owner: str, repo: str, ref: str, paths: list[str], read) -> list[tuple[str, str]]:379    """(path, content) for each path, each and in total size-capped."""380    out, total = [], 0381    for path in paths:382        if total >= TOTAL_CHARS:383            break384        try:385            text = read(f"/repos/{owner}/{repo}/contents/{gh.enc_path(path)}?ref={enc(ref)}")386        except RemoteError:387            continue  # a file that vanished or is too large for the API: skip it388        if not isinstance(text, str):389            continue390        if len(text) > FILE_CHARS:391            text = text[:FILE_CHARS] + f"\n… [truncated at {FILE_CHARS} characters]"392        text = text[: TOTAL_CHARS - total]393        total += len(text)394        out.append((path, text))395    return out396397398async def ask(model_id: str, system: str, user: str) -> str:399    response = await Gais.llm.chat_async(400        [{"role": "system", "content": system}, {"role": "user", "content": user}],401        model_id=model_id, temperature=0, thinking=False)402    return response.text403404405TERMS_PROMPT = """You turn a software plan into repository search terms.406Return ONLY JSON: {"terms": ["...", "..."]} — 6 to 15 short lowercase terms407that would appear in the FILE PATHS of code this plan touches or should reuse:408domain nouns, feature names, likely module or directory names. No sentences."""409410PICK_PROMPT = """You choose which files of a repository to read for a plan.411You see the repository's structure and a shortlist of paths. Return ONLY JSON:412{"files": ["path", ...]} with at most {limit} paths, copied EXACTLY from the413shortlist, most important first. Prefer: code the plan would reuse or change,414the entry points of the areas it touches, existing tests for those areas.415Never invent a path."""416417MAP_PROMPT = """You write a CODEBASE MAP: what a team must know about an418existing repository before building a plan in it. Before the plan is written,419the <plan> is just the user's request: map against that. Use ONLY the files provided420and the paths listed; never invent a path, a function or a convention.421422Write markdown with exactly these sections:423424## Conventions425Rules from the repository's own convention files that this plan must follow,426each with the file it comes from. Skip the section's items if none apply.427428## What already exists429For each part of the plan, the existing code that already does some or all of430it: `path` — what it does — how the plan should reuse it. Say plainly when a431part of the plan has nothing.432433## What does not exist yet434The parts of the plan that are genuinely new.435436## Where it touches shared code437Existing modules the plan would change that other features also depend on.438439Be specific and short. A reviewer will use this to judge the plan, so a wrong440path is worse than a missing one."""441442443async def build_map(owner, repo, ref, focus, max_files, model_id, call=None, base=""):444    read = call or (lambda path: connectors.github.request("GET", path, raw=True))445    get = call or (lambda path: connectors.github.request("GET", path))446447    tree = get(f"/repos/{owner}/{repo}/git/trees/{enc(ref)}?recursive=1")448    entries = tree.get("tree") if isinstance(tree, dict) else None449    if not isinstance(entries, list):450        raise ValueError(f"Could not list {owner}/{repo} at '{ref}'.")451    blobs = [e["path"] for e in entries if isinstance(e, dict) and e.get("type") == "blob" and e.get("path")]452    facts = repo_facts(owner, repo, ref, base, blobs, bool(tree.get("truncated")), get, read)453454    content = [p for p in blobs if not is_noise(p)]455    if len(content) <= SMALL_REPO:456        # Nothing to search: read it all, with no terms or pick call.457        chosen, candidates = [p for p in content if p not in CONVENTION_NAMES], content458    else:459        terms = parse_json(await ask(model_id, TERMS_PROMPT, focus)).get("terms") or []460        candidates = shortlist(blobs, terms)461        picks = parse_json(await ask(462            model_id, PICK_PROMPT.replace("{limit}", str(max_files)),463            f"<plan>\n{focus}\n</plan>\n\n<structure>\n" + "\n".join(structure(blobs))464            + "\n</structure>\n\n<shortlist>\n" + "\n".join(candidates) + "\n</shortlist>",465        )).get("files")466        chosen = keep_real(picks, set(blobs), max_files)467    picked_dirs = {p.rsplit("/", 1)[0] for p in chosen if "/" in p}468    conv = conventions_in(blobs, picked_dirs)469    files = read_files(owner, repo, ref, conv + [p for p in chosen if p not in conv], read)470471    body = "\n\n".join(f"<file path=\"{p}\">\n{t}\n</file>" for p, t in files)472    written = (await ask(473        model_id, MAP_PROMPT,474        f"<plan>\n{focus}\n</plan>\n\n<paths-seen>\n" + "\n".join(candidates[:200])475        + f"\n</paths-seen>\n\n{body}",476    )).strip()477    return {"map": f"{written}\n\n{render_facts(facts)}", "files": [p for p, _ in files],478            "ref": ref, "facts": facts}479480481def main():482    try:483        envelope = json.loads(sys.stdin.read() or "{}")484        inputs = envelope.get("inputs", {}) if isinstance(envelope, dict) else {}485        repo_in = inputs.get("repo") if isinstance(inputs.get("repo"), dict) else {}486        owner, repo = (repo_in.get("owner") or "").strip(), (repo_in.get("repo") or "").strip()487        ref = (repo_in.get("base_branch") or "").strip()488        base = str(inputs.get("base") or "").strip()489        focus = (inputs.get("focus") or "").strip()490        model_id = inputs.get("llmModelId")491        try:492            max_files = max(5, min(40, int(inputs.get("maxFiles") or 20)))493        except (TypeError, ValueError):494            max_files = 20495496        if not owner or not repo or not ref:497            raise ValueError("Wire the Working branch's `repo` output into Repository — owner, repo and branch are needed.")498        if not focus:499            raise ValueError("Nothing to map against: write the user's request (or the design doc) into the Design doc wired to Plan.")500        if not model_id:501            raise ValueError("Required input 'llmModelId' not provided")502        connectors.github.require()503504        print(f"[codebase-map] {owner}/{repo}@{ref} base={base or '(default)'} max_files={max_files}", file=sys.stderr)505        out = asyncio.run(build_map(owner, repo, ref, focus, max_files, model_id, base=base))506        b = out["facts"]["branch"]507        print(f"[codebase-map] read {len(out['files'])} files empty={out['facts']['empty']} "508              f"ahead={b.get('ahead_by')} prs_into={len(b.get('open_prs_into') or [])}", file=sys.stderr)509        json.dump(out, sys.stdout)510    except (ConfigError, RemoteError, ValueError) as e:511        print(json.dumps({"error": str(e), "errorType": type(e).__name__}), file=sys.stderr)512        sys.exit(1)513    except Exception as e:514        print(json.dumps({"error": str(e), "errorType": type(e).__name__,515                          "traceback": traceback.format_exc()}), file=sys.stderr)516        sys.exit(1)517518519if __name__ == "__main__":520    main()521

$ git log --oneline

v1.1.0
HEAD
2026-10-14

First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).