zz_

app/cli/commands/ladder-replay-commits.ts

zzmq.dev@gmail.com/zz / app / cli / commands / ladder-replay-commits.ts @ 5e5720fb Download
Ladder Summary
  • file The module implements the **“zz ladder replay‑commits”** CLI command: it walks a repository’s commit history, uses a model backend to synthesize human‑readable “rungs” describing each file‑level change (including per‑hunk line‑change summaries), and feeds those rungs into the repository’s lane/work‑queue so that the ladder’s state is updated and git notes are written. **Exported symbols** * **`ReplayOptions`** – interface describing optional CLI flags (`limit`, `force`, `maxFiles`). * **`ladderReplayCommits`** (the command handler – the exact name may be `replayLadderCommits` or the default export) – async function that coordinates cloning the repo, iterating commits, building mechanical and model‑driven `Rung`s via the helpers (`mechanicalFileChangeRung`, `buildFileChangeRung`, `buildUpdatedFileRung`), and invoking the lane protocol (`ask`, `mergeLaneLadder`, `proposeLane`) to merge each rung into the ladder. All other functions (`shortId`, `isPresent`, `mechanicalFileChangeRung`, `describeHunk`, `buildFileChangeRung`, `buildUpdatedFileRung`, etc.) are internal utilities used by the exported command handler.
// `zz ladder replay-commits <owner/repo> [--limit N]`: walks the commit
// log from the root commit forward through a real work queue
// (engine/workqueue.ts), not a hand-written call sequence. One Lane per
// commit, each building a changeset rung (nested filechange/linechange
// rungs) and asking the repo Lane's own queue to merge it -- the repo
// Lane is the only thing that ever writes git notes, and only when it
// gets around to processing that ask, not when the commit-Lane finishes
// its own work. Run `zz ladder build` first: this expects a file rung to
// already exist for files it updates (it won't create one from
// scratch, to avoid re-deriving the whole current-state tree here too).
import { randomBytes } from "node:crypto";
import { ensureCloned, resolveRepo } from "../lib/repo.js";
import { commitFileChanges, fileContentAtCommit, listCommitsChronological, type CommitRow, type FileChangeRow } from "../../engine/ingest.js";
import { resolveModelBackend, type ModelBackend } from "../../engine/model.js";
import { ask, mergeLaneLadder, proposeLane } from "../../engine/lane.js";
import { runWorkerLoop } from "../../engine/workqueue.js";
import type { Rung } from "../../engine/ladder.js";
import { REPO_RUNG_PATH } from "../../engine/ladder.js";
import { readAnchor, writeLadder } from "../../engine/ladderNotes.js";
import { readLane } from "../../engine/store.js";
import type { AskEntry, LaneRef } from "../../engine/types.js";

export interface ReplayOptions {
  limit?: number;
  force?: boolean;
  maxFiles?: number;
}

const DEFAULT_MAX_FILES = 25;

// A distinct name for the coordinator side of the protocol, even though
// one local model plays both parts in this bootstrap -- per the
// project's own framing, every actor (human or agent) is a client of
// the same protocol, and that includes "the coordinator" as a named
// party, not an implicit system behavior.
const AGENT = process.env.ZZ_AGENT ?? "zz-local";
const COORDINATOR = process.env.ZZ_COORDINATOR ?? "zz-coordinator";

function shortId(prefix: string): string {
  return `${prefix}-${randomBytes(4).toString("hex")}`;
}

async function isPresent(repoDir: string, stateDir: string, path: string): Promise<boolean> {
  return (await readAnchor(repoDir, stateDir, path)) !== null;
}

function mechanicalFileChangeRung(commit: CommitRow, change: FileChangeRow, fileCount: number): Rung {
  const note = change.status === "modified" ? `modified, ${change.hunks.length} hunk(s)` : change.status;
  return { path: change.path, kind: "filechange", summary: `${note}, as part of a ${fileCount}-file commit described at the commit level only.`, sources: [{ kind: "commit", ref: commit.sha, title: commit.message, author: commit.author, at: commit.at }], children: [] };
}

async function describeHunk(model: ModelBackend, path: string, hunk: string): Promise<string> {
  const described = await model.summarize(
    `Describe in one sentence what changed in this diff hunk from ${path}. Name the specific functions/identifiers added, removed, or changed. No caveats, no markdown.\n\n${hunk}\n---\nSummary:`,
  );
  return described ?? `change at ${hunk.split("\n")[0]}`;
}

async function buildFileChangeRung(model: ModelBackend, repoDir: string, commit: CommitRow, change: FileChangeRow): Promise<Rung> {
  if (change.status === "added") {
    const content = fileContentAtCommit(repoDir, commit.sha, change.path);
    const described = content.trim()
      ? (await model.summarize(`Describe in 1-2 sentences what this newly-added file contains.\n\nFile: ${change.path}\n---\n${content.slice(0, 6000)}\n---\nSummary:`)) ?? "new file."
      : "new empty file.";
    return { path: change.path, kind: "filechange", summary: `added. ${described}`, sources: [{ kind: "commit", ref: commit.sha, title: commit.message, author: commit.author, at: commit.at }], children: [] };
  }
  if (change.status === "deleted") {
    return { path: change.path, kind: "filechange", summary: "deleted.", sources: [{ kind: "commit", ref: commit.sha, title: commit.message, author: commit.author, at: commit.at }], children: [] };
  }
  const lineRungs: Rung[] = [];
  for (const hunk of change.hunks) {
    const header = hunk.split("\n")[0];
    const described = await describeHunk(model, change.path, hunk);
    lineRungs.push({ path: `${change.path}${header}`, kind: "linechange", summary: described, sources: [], children: [] });
  }
  const overview = change.hunks.length
    ? (await model.summarize(`Describe in one sentence the overall effect of this commit's changes to ${change.path}, based on the ${change.hunks.length} hunk(s) below.\n\n${change.hunks.join("\n\n")}\n---\nSummary:`)) ?? `modified, ${change.hunks.length} hunk(s).`
    : "modified.";
  return { path: change.path, kind: "filechange", summary: overview, sources: [{ kind: "commit", ref: commit.sha, title: commit.message, author: commit.author, at: commit.at }], children: lineRungs };
}

/**
 * Builds the updated current-state Rung for a file: a fresh synthesis of
 * its current description, not the old description with the latest
 * change tacked on. The model decides whether the change actually
 * matters -- a cosmetic change (formatting, whitespace, a rename with no
 * behavior change) shouldn't alter the description at all, let alone
 * read as if that cosmetic change were now the file's defining fact.
 * Sources are capped so provenance stays bounded; the full history lives
 * in the filechange rungs, this is a snapshot, not an archive. Returns
 * null when there's nothing safe to update (no existing anchor, or it
 * has nested children this pass can't preserve).
 */
async function buildUpdatedFileRung(model: ModelBackend, existing: Awaited<ReturnType<typeof readAnchor>>, commit: CommitRow, changeSummary: string): Promise<Rung | null> {
  if (!existing) return null;
  if (existing.children.length > 0) return null;
  const previousClean = existing.summary.replace(/ \(previously:.*\)$/s, "");
  const synthesized = await model.summarize(
    `You maintain a short, current description of what a file is and does. Below is its existing description and a note about what changed in the latest commit. Decide first whether the change is material -- does it actually alter the file's purpose, behavior, or exports? If not (formatting, whitespace, comments, a rename with no behavior change, reordering with no effect), output the existing description completely unchanged, with no mention of the latest commit at all -- not even a passing remark that something was reformatted. Only write a revised description, folding in what changed, when the change is actually material. Never append the new note onto the old description as an extra sentence.\n\nExisting description: ${previousClean}\n\nWhat changed in the latest commit: ${changeSummary}\n\nUpdated description:`,
  );
  return {
    path: existing.path,
    kind: existing.kind,
    summary: synthesized ?? `${changeSummary} (previously: ${previousClean})`,
    sources: [{ kind: "commit" as const, ref: commit.sha, title: commit.message, author: commit.author, at: commit.at }, ...existing.sources].slice(0, 5),
    touches: existing.touches,
    children: [],
  };
}

interface AddFileChangePayload {
  commit: CommitRow;
  change: FileChangeRow;
  large: boolean;
  fileCount: number;
}

interface SummarizeChangesetPayload {
  commit: CommitRow;
  large: boolean;
  fileCount: number;
}

export async function ladderReplayCommits(slug: string, options: ReplayOptions): Promise<void> {
  const repo = resolveRepo(slug);
  ensureCloned(repo);
  const model = await resolveModelBackend();
  console.error(`model backend: ${model.name}`);
  const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;

  let repoLane = readLane(repo.stateDir, REPO_RUNG_PATH);
  if (!repoLane) {
    repoLane = await proposeLane(repo.slug, { id: REPO_RUNG_PATH, repo: slug, name: slug, objective: `the ${slug} repository`, scope: ["**"], proposedBy: COORDINATOR, coordinators: [COORDINATOR] });
  }
  const repoOrigin: LaneRef = { repo: slug, lane: repoLane.id };

  // --- handlers that run on the repo Lane's own queue: the only place
  // git notes actually get written. -----------------------------------
  const repoHandlers = {
    merge_request: async (theAsk: AskEntry) => {
      const { ladder } = theAsk.payload as { ladder: Rung | Rung[] };
      await mergeLaneLadder(repo.slug, ladder);
    },
    rung_update_request: async (theAsk: AskEntry) => {
      const { path, commit, changeSummary } = theAsk.payload as { path: string; commit: CommitRow; changeSummary: string };
      const existing = await readAnchor(repo.repoDir, repo.stateDir, path);
      const updated = await buildUpdatedFileRung(model, existing, commit, changeSummary);
      if (updated) await writeLadder(repo.repoDir, repo.stateDir, updated);
    },
    ingest_repo: async () => {
      const commits = listCommitsChronological(repo.repoDir);
      const limit = options.limit ?? commits.length;
      const sessionLane = await proposeLane(repo.slug, {
        id: shortId("replay"),
        repo: slug,
        name: `replay session (${new Date().toISOString()})`,
        objective: "walk the commit log and build a changeset per commit",
        scope: ["**"],
        origin: repoOrigin,
        proposedBy: AGENT,
        coordinators: [AGENT],
      });

      let posted = 0;
      let skipped = 0;
      for (const commit of commits) {
        if (posted >= limit) break;
        if (!options.force && (await isPresent(repo.repoDir, repo.stateDir, `changesets/${commit.sha}`))) {
          skipped += 1;
          continue;
        }
        await ask(repo.slug, sessionLane.id, { kind: "analyze_changeset", from: AGENT, payload: { commit } });
        posted += 1;
      }
      console.error(`posted ${posted} analyze_changeset ask(s), ${skipped} commit(s) already onboarded`);

      await runWorkerLoop(repo.slug, sessionLane.id, AGENT, {
        analyze_changeset: async (theAsk) => {
          const { commit } = theAsk.payload as { commit: CommitRow };
          const changes = commitFileChanges(repo.repoDir, commit);
          const large = changes.length > maxFiles;
          console.error(`${commit.sha.slice(0, 7)} "${commit.message}" -- ${changes.length} file(s)${large ? " (large commit: mechanical only)" : ""}`);

          const changesetLane = await proposeLane(repo.slug, {
            id: `commit-${commit.sha.slice(0, 10)}`,
            repo: slug,
            name: `analyze commit ${commit.sha.slice(0, 7)}`,
            objective: commit.message,
            scope: [`changesets/${commit.sha}`],
            origin: { repo: slug, lane: sessionLane.id },
            proposedBy: AGENT,
            coordinators: [AGENT],
          });

          const fileAskIds: string[] = [];
          for (const change of changes) {
            const payload: AddFileChangePayload = { commit, change, large, fileCount: changes.length };
            const fileAsk = await ask(repo.slug, changesetLane.id, { kind: "add_filechange", from: AGENT, payload });
            fileAskIds.push(fileAsk.id);
          }
          const summarizePayload: SummarizeChangesetPayload = { commit, large, fileCount: changes.length };
          await ask(repo.slug, changesetLane.id, { kind: "summarize_changeset", from: AGENT, payload: summarizePayload, depends_on: fileAskIds });

          await runWorkerLoop(repo.slug, changesetLane.id, AGENT, {
            add_filechange: async (fcAsk) => {
              const { commit: c, change, large: isLarge, fileCount } = fcAsk.payload as AddFileChangePayload;
              const rung = isLarge ? mechanicalFileChangeRung(c, change, fileCount) : await buildFileChangeRung(model, repo.repoDir, c, change);
              // Non-blocking: posted into the repo Lane's queue, not
              // awaited here. The repo Lane's own loop (resumed once
              // this whole ingest_repo handler returns) will get to it.
              if (!isLarge) {
                await ask(repo.slug, repoLane!.id, { kind: "rung_update_request", from: AGENT, payload: { path: change.path, commit: c, changeSummary: rung.summary } });
              }
              return rung;
            },
            summarize_changeset: async (scAsk) => {
              const { commit: c, large: isLarge, fileCount } = scAsk.payload as SummarizeChangesetPayload;
              const lane = readLane(repo.stateDir, changesetLane.id)!;
              const fileChangeRungs = fileAskIds.map((id) => {
                const a = lane.history.find((e) => e.type === "ANSWER" && e.ask === id);
                return (a as { payload: Rung }).payload;
              });
              const changesetRung: Rung = {
                path: `changesets/${c.sha}`,
                kind: "changeset",
                summary: `${c.message} (${c.author}, ${c.at}). ${fileCount} file(s) changed.${isLarge ? " A large commit; files described at a glance, not individually." : ""}`,
                sources: [{ kind: "commit", ref: c.sha, title: c.message, author: c.author, at: c.at }],
                children: fileChangeRungs,
              };
              // Also non-blocking: fire the merge ask at the repo Lane's
              // queue and move on, same as the per-file rung updates.
              await ask(repo.slug, repoLane!.id, { kind: "merge_request", from: AGENT, payload: { ladder: changesetRung } });
              return changesetRung;
            },
          });
        },
      });

      return { processed: posted, skipped, remaining: commits.length - posted - skipped };
    },
  };

  await ask(repo.slug, repoLane.id, { kind: "ingest_repo", from: COORDINATOR });
  await runWorkerLoop(repo.slug, repoLane.id, COORDINATOR, repoHandlers);

  console.error("done.");
}