zz_

app/agent/tools.ts

zzmq.dev@gmail.com/zz / app / agent / tools.ts @ ae1f4f78 Download
Ladder Summary
  • file **What the file does (1‑3 sentences)** The module builds an in‑process catalog of “tools” that expose the underlying engine’s git, GitHub, and ladder‑lane capabilities as simple, JSON‑schema‑described functions. Each tool conforms to the `ToolDef` interface – it names the operation, documents its parameters, and implements an `execute` method that forwards the call to the appropriate engine routine (e.g., fetching commits, diffs, issues, PRs, releases, contributors, thread comments, or reading ladder rungs). Helper utilities (`shortId` and `requireAsk`) are provided for generating lightweight identifiers and for locating a specific ASK entry inside a lane. **Exported symbols (functions / classes / constants)** | Exported name | Kind | Brief purpose | |---------------|------|----------------| | `ToolDef` | TypeScript **interface** | Describes the shape of a tool (name, description, parameters, async execute). | | `git_log` | `ToolDef` **constant** | Lists all commits on a repository’s default branch (first‑parent, oldest‑first). | | `git_diff` | `ToolDef` **constant** | Returns the list of changed files and diff hunks for a given commit SHA. | | `git_show_file` | `ToolDef` **constant** | Retrieves the full content of a file at a specific commit. | | `gh_fetch_issues` | `ToolDef` **constant** | Pulls every issue (title, state, author, comment count, body) from a repo’s GitHub page. | | `gh_fetch_pull_requests` | `ToolDef` **constant** | Pulls every pull request from a repo’s GitHub page. | | `gh_fetch_releases` | `ToolDef` **constant** | Pulls every GitHub release for the repo. | | `gh_fetch_contributors` | `ToolDef` **constant** | Pulls every contributor recorded on GitHub for the repo, together with commit counts. | | `gh_fetch_thread_comments` | `ToolDef` **constant** | Retrieves the comment thread for a specific issue or PR. | | `ladder_read` | `ToolDef` **constant** | Reads a single rung (file) from the repo’s Ladder by path, returning `null` if it does not exist. | | *(additional tool constants that follow in the file, e.g., lane‑related tools such as `ladder_write`, `lane_propose`, etc.)* | `ToolDef` **constants** | Wrap the lane‑engine APIs (`ask`, `answer`, `mergeLaneLadder`, `proposeLane`, `addRelatedLane`, …). | *(If the file ends with a collective export such as `export const tools = [git_log, git_diff, …];` that export would also be part of the public API, but the core exported symbols are the `ToolDef` interface and the individual `ToolDef` objects listed above.)*
 // The in-process tool catalog from site/content/mcp/index.md: zz-git and
 // zz-ladder-lanes. Every tool is a thin wrapper over an existing engine
 // function -- no new business logic here. Judgment (is a change
 // material, how to decompose a task, when work is done) stays in the
 // calling agent's own reasoning, never inside a tool.
 import { randomBytes } from "node:crypto";
 import { ensureCloned, resolveRepo } from "../cli/lib/repo.js";
 import {
  commitFileChanges,
  fetchContributors,
  fetchIssues,
  fetchPullRequests,
  fetchReleases,
  fetchThreadComments,
  fileContentAtCommit,
  listCommitsChronological,
 } from "../engine/ingest.js";
 import { ask, answer, mergeLaneLadder, proposeLane } from "../engine/lane.js";
 import { claimableAsks } from "../engine/workqueue.js";
 import { readAnchor } from "../engine/ladderNotes.js";
 import { readLane } from "../engine/store.js";
 import type { Rung } from "../engine/ladder.js";
 import type { AskEntry, AskKind, LaneRef } from "../engine/types.js";
 
 export interface ToolDef {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  execute(args: Record<string, unknown>): Promise<unknown>;
 }
 
 function shortId(prefix: string): string {
  return `${prefix}-${randomBytes(4).toString("hex")}`;
 }
 
 function requireAsk(lane: ReturnType<typeof readLane>, askId: string): AskEntry {
  if (!lane) throw new Error("no such lane");
  const found = lane.history.find((e) => e.type === "ASK" && e.id === askId);
  if (!found) throw new Error(`no such ask: ${askId}`);
  return found as AskEntry;
 }
 
 // --- zz-git: read-only data fetching, nothing writes anything ----------
 
 const gitLog: ToolDef = {
  name: "git_log",
  description: "List every commit on the repo's default branch, oldest first (first-parent only).",
  parameters: { type: "object", properties: { repo: { type: "string" } }, required: ["repo"] },
  async execute({ repo }) {
  const r = resolveRepo(repo as string);
  ensureCloned(r);
  return listCommitsChronological(r.repoDir);
  },
 };
 
 const gitDiff: ToolDef = {
  name: "git_diff",
  description: "What changed in one commit: which files, how (added/modified/deleted), and the diff hunks for modified files.",
  parameters: { type: "object", properties: { repo: { type: "string" }, sha: { type: "string" } }, required: ["repo", "sha"] },
  async execute({ repo, sha }) {
  const r = resolveRepo(repo as string);
  const commits = listCommitsChronological(r.repoDir);
  const commit = commits.find((c) => c.sha === sha);
  if (!commit) throw new Error(`no such commit: ${sha}`);
  return commitFileChanges(r.repoDir, commit);
  },
 };
 
 const gitShowFile: ToolDef = {
  name: "git_show_file",
  description: "A file's full content as of a specific commit.",
  parameters: { type: "object", properties: { repo: { type: "string" }, sha: { type: "string" }, path: { type: "string" } }, required: ["repo", "sha", "path"] },
  async execute({ repo, sha, path }) {
  const r = resolveRepo(repo as string);
  return fileContentAtCommit(r.repoDir, sha as string, path as string);
  },
 };
 
 const ghFetchIssues: ToolDef = {
  name: "gh_fetch_issues",
  description: "Every issue on the repo's GitHub page (title, state, author, comment count, body).",
  parameters: { type: "object", properties: { repo: { type: "string" } }, required: ["repo"] },
  async execute({ repo }) {
  return fetchIssues(repo as string);
  },
 };
 
 const ghFetchPullRequests: ToolDef = {
  name: "gh_fetch_pull_requests",
  description: "Every pull request on the repo's GitHub page.",
  parameters: { type: "object", properties: { repo: { type: "string" } }, required: ["repo"] },
  async execute({ repo }) {
  return fetchPullRequests(repo as string);
  },
 };
 
 const ghFetchReleases: ToolDef = {
  name: "gh_fetch_releases",
  description: "Every GitHub release for the repo.",
  parameters: { type: "object", properties: { repo: { type: "string" } }, required: ["repo"] },
  async execute({ repo }) {
  return fetchReleases(repo as string);
  },
 };
 
 const ghFetchContributors: ToolDef = {
  name: "gh_fetch_contributors",
  description: "Every contributor GitHub has recorded for the repo, with their commit count.",
  parameters: { type: "object", properties: { repo: { type: "string" } }, required: ["repo"] },
  async execute({ repo }) {
  return fetchContributors(repo as string);
  },
 };
 
 const ghFetchThreadComments: ToolDef = {
  name: "gh_fetch_thread_comments",
  description: "The comment thread on one issue or PR.",
  parameters: { type: "object", properties: { repo: { type: "string" }, number: { type: "number" } }, required: ["repo", "number"] },
  async execute({ repo, number }) {
  return fetchThreadComments(repo as string, number as number);
  },
 };
 
 // --- zz-ladder-lanes: Ask/Answer/Lane/Ladder -----------------------------
 
 const ladderRead: ToolDef = {
  name: "ladder_read",
  description: "Read one rung from the repo's Ladder by path (e.g. \"_repo\", \"index.js\", \"asks/42\"). Returns null if it doesn't exist yet.",
  parameters: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" } }, required: ["repo", "path"] },
  async execute({ repo, path }) {
  const r = resolveRepo(repo as string);
  return readAnchor(r.repoDir, r.stateDir, path as string);
  },
 };
 
 const VALID_SOURCE_KINDS = new Set(["commit", "pr", "issue"]);
 
 // Fills in sources/children with [] when omitted, recursively, so a
 // slightly incomplete rung from the model doesn't crash deep inside
 // ladderNotes.ts with an opaque "reading 'map' of undefined" error.
 // Malformed source entries (missing kind or ref) are dropped rather than
 // kept with "undefined" fields -- a rung that needed one and only has a
 // broken one should hit the clear "no commit source" error below, not
 // silently render "undefined" everywhere a title/author/at was missing.
 // A string field from the model might arrive as an object ({name,
 // email}, say) instead of plain text -- stringify it sensibly rather
 // than let it render as the literal text "[object Object]".
 function asText(value: unknown): string {
  if (value == null) return "";
  if (typeof value === "string") return value;
  if (typeof value === "object") {
  const o = value as Record<string, unknown>;
  return String(o.name ?? o.login ?? o.title ?? JSON.stringify(value));
  }
  return String(value);
 }
 
 function normalizeRung(rung: unknown): Rung {
  const r = rung as Partial<Rung> & Record<string, unknown>;
  const rawSources = Array.isArray(r.sources) ? r.sources : [];
  const sources = rawSources
  .filter((s): s is NonNullable<typeof s> => !!s && typeof s === "object" && VALID_SOURCE_KINDS.has((s as { kind?: string }).kind ?? "") && !!(s as { ref?: string }).ref)
  .map((s) => ({ kind: s.kind, ref: asText(s.ref), title: asText(s.title), author: asText(s.author), at: asText(s.at) }));
  return {
  path: asText(r.path),
  kind: r.kind as Rung["kind"],
  summary: asText(r.summary),
  sources,
  touches: Array.isArray((r as { touches?: unknown }).touches) ? (r as { touches?: string[] }).touches : undefined,
  children: Array.isArray(r.children) ? r.children.map(normalizeRung) : [],
  };
 }
 
 const RUNG_KINDS_NEEDING_A_COMMIT_SOURCE = new Set(["repo", "directory", "file", "release", "changeset"]);
 const VALID_TOP_LEVEL_KINDS = new Set(["repo", "directory", "file", "release", "changeset", "ask", "contributor", "dependencies"]);
 
+// Small local models follow a literal example far more reliably than a
+// prose description of the right shape -- appended to every validation
+// error below so there's always something concrete to copy, not just
+// something to interpret.
+const VALID_RUNG_EXAMPLE = JSON.stringify({
+ path: "changesets/abc123",
+ kind: "changeset",
+ summary: "Fixed the off-by-one error in the pagination helper.",
+ sources: [{ kind: "commit", ref: "abc123def456...", title: "Fix pagination off-by-one", author: "Jane Doe", at: "2026-01-01T00:00:00Z" }],
+ children: [{ path: "pagination.js", kind: "filechange", summary: "Changed the loop bound from <= to < to fix the off-by-one.", sources: [], children: [] }],
+});
+
 function validateTopLevelKind(rung: Rung): string | null {
  if (!VALID_TOP_LEVEL_KINDS.has(rung.kind)) {
- return `rung "${rung.path}" has kind "${rung.kind}", which can't be written as a top-level rung -- it has no git object of its own to anchor to. Nest it inside its parent's children array instead (e.g. a "filechange" goes inside a "changeset"'s children, not alongside it).`;
+ return `rung "${rung.path}" has kind "${rung.kind}", which can't be written as a top-level rung -- it has no git object of its own to anchor to. Nest it inside its parent's children array instead (e.g. a "filechange" goes inside a "changeset"'s children, not alongside it). Example of a correct call's rung argument: ${VALID_RUNG_EXAMPLE}`;
  }
  return null;
 }
 
 function validateAnchorSources(rung: Rung): string | null {
  if (RUNG_KINDS_NEEDING_A_COMMIT_SOURCE.has(rung.kind) && !rung.sources.some((s) => s.kind === "commit" && s.ref)) {
- return `rung "${rung.path}" (kind "${rung.kind}") has no commit source to anchor its note to. Add a sources entry: {kind: "commit", ref: "<the real commit sha this rung is about>", title, author, at}.`;
+ return `rung "${rung.path}" (kind "${rung.kind}") has no commit source to anchor its note to. Add a sources entry: {kind: "commit", ref: "<the real commit sha this rung is about>", title, author, at}. Example of a correct call's rung argument: ${VALID_RUNG_EXAMPLE}`;
  }
  return null;
 }
 
 function validateHasSummary(rung: Rung): string | null {
- if (!rung.summary.trim()) return `rung "${rung.path}" has an empty summary. Every rung needs a real description -- if it shouldn't be there at all, leave it out of children entirely rather than including it with nothing to say.`;
+ if (!rung.summary.trim()) return `rung "${rung.path}" has an empty summary. Every rung needs a real description -- if it shouldn't be there at all, leave it out of children entirely rather than including it with nothing to say. Example of a correct call's rung argument: ${VALID_RUNG_EXAMPLE}`;
  for (const child of rung.children) {
  const problem = validateHasSummary(child);
  if (problem) return problem;
  }
  return null;
 }
 
 const ladderWriteRung: ToolDef = {
  name: "ladder_write_rung",
  description:
  'Write a rung (or several top-level rungs) into the repo\'s Ladder. The only tool that actually changes stored Ladder state -- use it both to merge a finished changeset/ask/etc and to update a file\'s current-state rung. Only call this if you hold a coordinator role for the Lane whose scope covers this path (see the system prompt). Every rung needs: path, kind, summary, sources (array, [] if none), children (array, [] if none). A rung of kind "repo", "directory", "file", "release", or "changeset" additionally MUST have at least one sources entry shaped {kind: "commit", ref: "<real commit sha>", title, author, at} -- that commit is what its git note gets anchored to.',
  parameters: {
  type: "object",
  properties: {
  repo: { type: "string" },
  rung: {
  description: "A Rung object, or an array of them for several top-level rungs at once.",
  oneOf: [
  {
  type: "object",
  properties: {
  path: { type: "string" },
  kind: { type: "string", enum: ["repo", "directory", "file", "function", "lines", "release", "package", "ask", "answer", "contributor", "dependencies", "changeset", "filechange", "linechange"] },
  summary: { type: "string" },
  sources: { type: "array", items: { type: "object", properties: { kind: { type: "string", enum: ["commit", "pr", "issue"] }, ref: { type: "string" }, title: { type: "string" }, author: { type: "string" }, at: { type: "string" } } } },
  children: { type: "array", items: { type: "object" } },
  },
  required: ["path", "kind", "summary", "sources", "children"],
  },
  { type: "array" },
  ],
  },
  },
  required: ["repo", "rung"],
  },
  async execute({ repo, rung }) {
  const r = resolveRepo(repo as string);
  const rungs = (Array.isArray(rung) ? rung : [rung]).map(normalizeRung);
  for (const one of rungs) {
  const problem = validateTopLevelKind(one) ?? validateAnchorSources(one) ?? validateHasSummary(one);
  if (problem) throw new Error(problem);
  }
  await mergeLaneLadder(r.repoDir, r.stateDir, rungs);
  return { ok: true, wrote: rungs.map((x) => x.path) };
  },
 };
 
 const laneProposeTool: ToolDef = {
  name: "lane_propose",
  description: "Propose a new Lane: a workstream with its own Ask queue and local Ladder. Returns the created Lane, including its generated id.",
  parameters: {
  type: "object",
  properties: {
  repo: { type: "string" },
  name: { type: "string" },
  objective: { type: "string" },
  scope: { type: "array", items: { type: "string" }, description: "Ladder paths this Lane's work covers" },
  origin_lane: { type: "string", description: "The lane id this one originates from, if any" },
  actor: { type: "string", description: "Your agent id -- becomes this Lane's coordinator" },
  },
  required: ["repo", "name", "objective", "scope", "actor"],
  },
  async execute({ repo, name, objective, scope, origin_lane, actor }) {
  const r = resolveRepo(repo as string);
  const origin: LaneRef | undefined = origin_lane ? { repo: r.slug, lane: origin_lane as string } : undefined;
  return proposeLane(r.stateDir, {
  id: shortId("lane"),
  repo: r.slug,
  name: name as string,
  objective: objective as string,
  scope: scope as string[],
  proposedBy: actor as string,
  coordinators: [actor as string],
  origin,
  });
  },
 };
 
 const laneGet: ToolDef = {
  name: "lane_get",
  description: "Read a Lane's full record: status, scope, coordinators, and its complete Ask/Answer history.",
  parameters: { type: "object", properties: { repo: { type: "string" }, lane_id: { type: "string" } }, required: ["repo", "lane_id"] },
  async execute({ repo, lane_id }) {
  const r = resolveRepo(repo as string);
  return readLane(r.stateDir, lane_id as string);
  },
 };
 
 const askPostTool: ToolDef = {
  name: "ask_post",
  description:
  "Post a new Ask into a Lane's queue. Use kind \"work\" with a prose payload.description for anything without a more specific kind. Set depends_on to other ask ids that must be answered before this one may be claimed.",
  parameters: {
  type: "object",
  properties: {
  repo: { type: "string" },
  lane_id: { type: "string" },
  kind: { type: "string" },
  from: { type: "string" },
  payload: { type: "object" },
  depends_on: { type: "array", items: { type: "string" } },
  priority: { type: "number" },
  },
  required: ["repo", "lane_id", "kind", "from"],
  },
  async execute({ repo, lane_id, kind, from, payload, depends_on, priority }) {
  const r = resolveRepo(repo as string);
  return ask(r.stateDir, lane_id as string, { kind: kind as AskKind, from: from as string, payload, depends_on: depends_on as string[] | undefined, priority: priority as number | undefined });
  },
 };
 
 const askQueryClaimable: ToolDef = {
  name: "ask_query_claimable",
  description: "List every Ask in a Lane's queue that's unanswered, has all its dependencies met, and you're permitted to act on.",
  parameters: { type: "object", properties: { repo: { type: "string" }, lane_id: { type: "string" }, actor: { type: "string" } }, required: ["repo", "lane_id", "actor"] },
  async execute({ repo, lane_id, actor }) {
  const r = resolveRepo(repo as string);
  const lane = readLane(r.stateDir, lane_id as string);
  if (!lane) throw new Error(`no such lane: ${lane_id}`);
  return claimableAsks(lane, actor as string);
  },
 };
 
 const askClaim: ToolDef = {
  name: "ask_claim",
  description: "Claim an Ask: posts a claim_request and (self-)grants it. Returns the claimed Ask's full content so you can act on it.",
  parameters: { type: "object", properties: { repo: { type: "string" }, lane_id: { type: "string" }, ask_id: { type: "string" }, actor: { type: "string" } }, required: ["repo", "lane_id", "ask_id", "actor"] },
  async execute({ repo, lane_id, ask_id, actor }) {
  const r = resolveRepo(repo as string);
  const lane = readLane(r.stateDir, lane_id as string);
  const target = requireAsk(lane, ask_id as string);
  const grantedBy = lane!.coordinators[0] ?? actor;
  const claim = ask(r.stateDir, lane_id as string, { kind: "claim_request", from: actor as string, payload: { target: target.id } });
  answer(r.stateDir, lane_id as string, claim, { from: grantedBy, action: "grant", payload: { grant: `grant-${claim.id}` } });
  return target;
  },
 };
 
 const askAnswerTool: ToolDef = {
  name: "ask_answer",
  description: "Answer an Ask you've claimed: action \"complete\" with the result as payload when you've finished the work, or \"deny\" if you can't.",
  parameters: {
  type: "object",
  properties: {
  repo: { type: "string" },
  lane_id: { type: "string" },
  ask_id: { type: "string" },
  actor: { type: "string" },
  action: { type: "string", enum: ["complete", "deny"] },
  payload: { type: "object" },
  },
  required: ["repo", "lane_id", "ask_id", "actor", "action"],
  },
  async execute({ repo, lane_id, ask_id, actor, action, payload }) {
  const r = resolveRepo(repo as string);
  const lane = readLane(r.stateDir, lane_id as string);
  const target = requireAsk(lane, ask_id as string);
  return answer(r.stateDir, lane_id as string, target, { from: actor as string, action: action as "complete" | "deny", payload });
  },
 };
 
 export const GIT_TOOLS: ToolDef[] = [gitLog, gitDiff, gitShowFile, ghFetchIssues, ghFetchPullRequests, ghFetchReleases, ghFetchContributors, ghFetchThreadComments];
 export const LADDER_LANE_TOOLS: ToolDef[] = [ladderRead, ladderWriteRung, laneProposeTool, laneGet, askPostTool, askQueryClaimable, askClaim, askAnswerTool];
 export const ALL_TOOLS: ToolDef[] = [...GIT_TOOLS, ...LADDER_LANE_TOOLS];