app/cli/commands/ladder-build.ts
zzmq.dev@gmail.com/zz / app / cli / commands / ladder-build.ts
@ 4942dbdb
Download
Ladder Summary
- file This module implements the **“zz ladder build”** command that boot‑straps a repository by replaying its GitHub history through the real Lane/ASK/ANSWER protocol (instead of writing ladder rungs directly). It exports a set of helper functions that create the necessary “ask” and “release” ladder items—`runItemLane`, `isPresent`, `buildAskFromIssue`, `buildAskFromPr`, and `buildRelease`—and an options interface `BuildOptions`; all of them orchestrate proposing a lane, claiming it, performing the work (summarizing issues/PRs via a local model), and finally merging the generated ladder rung.
// `zz ladder build <owner/repo>`: onboards a repo by replaying its
// history through the real Lane/ASK/ANSWER protocol (engine/lane.ts),
// not by writing Ladder rungs directly. One local agent (configurable
// id, default "zz-local") plays both sides of the protocol -- it's the
// single actor in the system for this bootstrap step, holding
// Maintainer-level access by construction (no role-grant ceremony yet,
// see the plan). Any content analysis goes through a local Ollama model
// (engine/model.ts); nothing here ever shells out to another CLI.
import { ensureCloned, resolveRepo } from "../lib/repo.js";
import {
fetchContributorCommitMessages,
fetchContributors,
fetchDependentsSummary,
fetchIssues,
fetchPullRequests,
fetchReleases,
fetchThreadComments,
findDependencyUsage,
parseDependencies,
resolveTagCommit,
walkFileTree,
type ContributorRow,
type IssueRow,
type PrRow,
type ReleaseRow,
} from "../../engine/ingest.js";
import { resolveModelBackend, type ModelBackend } from "../../engine/model.js";
import { ask, answer, mergeLaneLadder, proposeLane } from "../../engine/lane.js";
import type { Rung } from "../../engine/ladder.js";
import { REPO_RUNG_PATH } from "../../engine/ladder.js";
import { readAnchor } from "../../engine/ladderNotes.js";
import { readLane } from "../../engine/store.js";
import type { LaneRef } from "../../engine/types.js";
export interface BuildOptions {
force?: boolean;
}
const AGENT = process.env.ZZ_AGENT ?? "zz-local";
// Same identity ladder-replay-commits.ts uses for the repo Lane's own
// coordinator -- both commands touch the same `_repo` Lane, and whoever
// creates it first fixes its `coordinators` list, so they need to agree.
const COORDINATOR = process.env.ZZ_COORDINATOR ?? "zz-coordinator";
/**
* Runs one history item through the real protocol: propose, claim
* (ask+answer), do the work, ask to merge (ask+answer), merge. The same
* agent plays both sides -- this is the bootstrap single-actor case the
* plan describes, not a shortcut around the protocol.
*/
async function runItemLane(
repoDir: string,
stateDir: string,
input: { id: string; repo: string; name: string; objective: string; scope: string[]; origin: LaneRef },
doWork: () => Promise<Rung | Rung[]>,
): Promise<void> {
const lane = await proposeLane(input.repo, { ...input, proposedBy: AGENT });
const claim = await ask(input.repo, lane.id, { kind: "claim_request", from: AGENT });
await answer(input.repo, lane.id, claim, { from: AGENT, action: "grant" });
const laneLadder = await doWork();
const mergeRequest = await ask(input.repo, lane.id, { kind: "merge_request", from: AGENT });
await answer(input.repo, lane.id, mergeRequest, { from: AGENT, action: "grant" });
await mergeLaneLadder(input.repo, laneLadder);
}
async function isPresent(repoDir: string, stateDir: string, path: string): Promise<boolean> {
return (await readAnchor(repoDir, stateDir, path)) !== null;
}
function askPrompt(input: { kind: "issue" | "pr"; number: number; title: string; author: string; body: string; outcome: string; comments: { author: string; body: string }[] }): string {
const thread = input.comments.map((c) => `${c.author}: ${c.body}`).join("\n---\n");
return `Describe in 2-4 sentences what this GitHub ${input.kind} asked for and what happened (outcome: ${input.outcome}). Be concrete about the technical content, not a restatement of the title. Use only the names given below -- never invent a username.
#${input.number} (opened by ${input.author}): ${input.title}
${input.body || "(no body)"}
Thread:
${thread || "(no comments)"}
---
Summary:`;
}
async function buildAskFromIssue(repoDir: string, stateDir: string, origin: LaneRef, slug: string, model: ModelBackend, i: IssueRow): Promise<void> {
const path = `asks/${i.number}`;
if (await isPresent(repoDir, stateDir, path)) return;
const outcome = i.state === "closed" ? "closed" : "open";
await runItemLane(repoDir, stateDir, { id: `ask-${i.number}`, repo: slug, name: `onboard issue #${i.number}`, objective: i.title, scope: [path], origin }, async () => {
const comments = fetchThreadComments(slug, i.number);
const summary = (await model.summarize(askPrompt({ kind: "issue", number: i.number, title: i.title, author: i.user, body: i.body ?? "", outcome, comments }))) ?? `${i.user}: "${i.title}" (${outcome}, ${i.comments} comments).`;
return { path, kind: "ask", summary, sources: [{ kind: "issue", ref: `#${i.number}`, title: i.title, author: i.user, at: i.created_at }], children: [] };
});
}
async function buildAskFromPr(repoDir: string, stateDir: string, origin: LaneRef, slug: string, model: ModelBackend, p: PrRow): Promise<void> {
const path = `asks/${p.number}`;
if (await isPresent(repoDir, stateDir, path)) return;
const outcome = p.merged_at ? "merged" : p.state === "closed" ? "closed without merging" : "open";
await runItemLane(repoDir, stateDir, { id: `ask-${p.number}`, repo: slug, name: `onboard PR #${p.number}`, objective: p.title, scope: [path], origin }, async () => {
const comments = fetchThreadComments(slug, p.number);
const summary = (await model.summarize(askPrompt({ kind: "pr", number: p.number, title: p.title, author: p.user, body: p.body ?? "", outcome, comments }))) ?? `${p.user}: "${p.title}" (${outcome}).`;
return { path, kind: "ask", summary, sources: [{ kind: "pr", ref: `#${p.number}`, title: p.title, author: p.user, at: p.created_at }], children: [] };
});
}
async function buildRelease(repoDir: string, stateDir: string, origin: LaneRef, slug: string, r: ReleaseRow): Promise<void> {
const path = `releases/${r.tag_name}`;
if (await isPresent(repoDir, stateDir, path)) return;
await runItemLane(repoDir, stateDir, { id: `release-${r.tag_name}`, repo: slug, name: `onboard release ${r.tag_name}`, objective: r.tag_name, scope: [path], origin }, async () => {
const firstLine = (r.body ?? "").split("\n").find((l) => l.trim().length > 0) ?? "(no release notes)";
const sha = resolveTagCommit(repoDir, r.tag_name);
return { path, kind: "release", summary: `${r.tag_name}, published ${r.published_at}. ${firstLine}`, sources: [{ kind: "commit", ref: sha, title: r.tag_name, author: r.author ?? "unknown", at: r.published_at }], children: [] };
});
}
async function buildContributor(repoDir: string, stateDir: string, origin: LaneRef, slug: string, model: ModelBackend, c: ContributorRow): Promise<void> {
const path = `contributors/${c.login}`;
if (await isPresent(repoDir, stateDir, path)) return;
await runItemLane(repoDir, stateDir, { id: `contributor-${c.login}`, repo: slug, name: `onboard contributor ${c.login}`, objective: c.login, scope: [path], origin }, async () => {
const messages = fetchContributorCommitMessages(slug, c.login);
const prompt = `Describe in 1-2 sentences the specific areas or kinds of changes this contributor made, based on their commit messages below.\n\nContributor: ${c.login}\nCommit messages:\n${messages.slice(0, 60).join("\n")}\n---\nSummary:`;
const described = messages.length ? await model.summarize(prompt) : null;
const summary = `${c.login}: ${c.contributions} commits.${described ? ` ${described}` : ""}`;
return { path, kind: "contributor", summary, sources: [], children: [] };
});
}
async function buildDependency(repoDir: string, stateDir: string, origin: LaneRef, slug: string, model: ModelBackend, name: string, versionRange: string): Promise<void> {
const path = `dependencies/${name}`;
if (await isPresent(repoDir, stateDir, path)) return;
await runItemLane(repoDir, stateDir, { id: `dependency-${name}`, repo: slug, name: `onboard dependency ${name}`, objective: name, scope: [path], origin }, async () => {
const usage = findDependencyUsage(repoDir, name);
const snippetText = usage.slice(0, 20).map((s) => `${s.file}: ${s.line.trim()}`).join("\n");
const described = usage.length ? await model.summarize(`Describe in 1-2 sentences how this dependency is used in this codebase, based on the source lines below.\n\nDependency: ${name}\nUsage sites:\n${snippetText}\n---\nSummary:`) : null;
const summary = `${name} (${versionRange}).${described ? ` ${described}` : " No usage sites found by search."}`;
return { path, kind: "dependencies", summary, sources: [], children: [] };
});
}
export async function ladderBuild(slug: string, options: BuildOptions): Promise<void> {
const repo = resolveRepo(slug);
ensureCloned(repo);
const model = await resolveModelBackend();
console.error(`model backend: ${model.name}`);
// The repo is its own long-lived Lane: proposed once, transitioned to
// active, and never merged -- everything else onboarded below has it
// as origin.
let repoLane = readLane(repo.stateDir, REPO_RUNG_PATH);
if (!repoLane) {
repoLane = await proposeLane(slug, { id: REPO_RUNG_PATH, repo: slug, name: slug, objective: `the ${slug} repository`, scope: ["**"], proposedBy: COORDINATOR, coordinators: [COORDINATOR] });
const review = await ask(slug, repoLane.id, { kind: "review_request", from: AGENT });
await answer(slug, repoLane.id, review, { from: COORDINATOR, action: "grant" });
}
const origin: LaneRef = { repo: slug, lane: repoLane.id };
if (!(await isPresent(repo.repoDir, repo.stateDir, REPO_RUNG_PATH)) || options.force) {
console.error("describing the file tree (one Lane for the whole tree; a model call per file)...");
let done = 0;
await runItemLane(repo.repoDir, repo.stateDir, { id: "file-tree", repo: slug, name: "describe the file tree", objective: "describe every tracked file and directory", scope: ["**"], origin }, async () => {
const topLevel = await walkFileTree(repo.repoDir, async (path, content, mechanical) => {
done += 1;
if (done % 5 === 0) console.error(` files: ${done}`);
if (!content.trim()) return mechanical;
const described = await model.summarize(`Describe in 1-3 sentences what this file does, based on its content below. Name exported functions/classes by name.\n\nFile: ${path}\n---\n${content.slice(0, 6000)}\n---\nSummary:`);
return described ?? mechanical;
});
const root: Rung = { path: REPO_RUNG_PATH, kind: "repo", summary: `${slug}, onboarded via \`zz ladder build\`.`, sources: [], children: [] };
return [root, ...topLevel];
});
} else {
console.error("file tree already onboarded; leaving it alone (pass --force to rebuild it).");
}
console.error("fetching issues, PRs, releases, contributors, dependencies...");
const issues = fetchIssues(repo.slug);
const prs = fetchPullRequests(repo.slug);
const releases = fetchReleases(repo.slug);
const contributors = fetchContributors(repo.slug);
const dependencies = parseDependencies(repo.repoDir);
const dependents = fetchDependentsSummary(repo.slug);
for (const i of issues) await buildAskFromIssue(repo.repoDir, repo.stateDir, origin, repo.slug, model, i);
for (const p of prs) await buildAskFromPr(repo.repoDir, repo.stateDir, origin, repo.slug, model, p);
for (const r of releases) await buildRelease(repo.repoDir, repo.stateDir, origin, repo.slug, r);
for (const c of contributors) await buildContributor(repo.repoDir, repo.stateDir, origin, repo.slug, model, c);
for (const d of dependencies) await buildDependency(repo.repoDir, repo.stateDir, origin, repo.slug, model, d.name, d.versionRange);
if (dependents && !(await isPresent(repo.repoDir, repo.stateDir, "dependents"))) {
await runItemLane(repo.repoDir, repo.stateDir, { id: "dependents", repo: repo.slug, name: "onboard dependents", objective: "dependents", scope: ["dependents"], origin }, async () => ({
path: "dependents",
kind: "dependencies",
summary: dependents,
sources: [],
children: [],
}));
}
console.error("done.");
}