zz_

app/agent/tools.ts

zzmq.dev@gmail.com/zz / app / agent / tools.ts @ 5ab93f67 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 { commitChangeset, type ChangesetFile } from "../engine/changeset.js";
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
 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). 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}. 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. 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" },
+ origin_repo: { type: "string", description: "The repo origin_lane lives in, if different from `repo` -- set this for a real cross-repo dependency (e.g. this Lane exists because of a fix in another repo's Lane), not just this repo's own history" },
  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 }) {
+ async execute({ repo, name, objective, scope, origin_lane, origin_repo, actor }) {
  const r = resolveRepo(repo as string);
- const origin: LaneRef | undefined = origin_lane ? { repo: r.slug, lane: origin_lane as string } : undefined;
+ const origin: LaneRef | undefined = origin_lane ? { repo: (origin_repo as string) ?? 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 });
  },
 };
 
+// --- real code: committing it, and testing it before committing -------
+
+function validateChangesetFiles(files: unknown): string | null {
+ if (!Array.isArray(files) || files.length === 0) return 'files must be a non-empty array of {path, content}. Example: [{"path": "index.js", "content": "..."}]';
+ for (let i = 0; i < files.length; i++) {
+ const f = files[i] as Record<string, unknown>;
+ if (!f || typeof f !== "object") return `files[${i}] must be an object {path, content}, got ${JSON.stringify(f)}`;
+ if (typeof f.path !== "string" || !f.path) return `files[${i}] is missing a non-empty "path" string.`;
+ if (typeof f.content !== "string") return `files[${i}] ("${f.path}") is missing a "content" string.`;
+ }
+ return null;
+}
+
+const commitChangesetTool: ToolDef = {
+ name: "commit_changeset",
+ description:
+ "Commits file content as a git commit -- the only tool that writes code. No working-tree file is needed first: content lives entirely in this call (the same content you should have already posted in a submit_changeset ask). Call this once you're ready to commit, after posting that ask, mirroring how ladder_write_rung is only called once a merge_request has been posted.",
+ parameters: {
+ type: "object",
+ properties: {
+ repo: { type: "string" },
+ files: { type: "array", items: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
+ message: { type: "string" },
+ author_name: { type: "string" },
+ author_email: { type: "string" },
+ },
+ required: ["repo", "files", "message", "author_name", "author_email"],
+ },
+ async execute({ repo, files, message, author_name, author_email }) {
+ const problem = validateChangesetFiles(files);
+ if (problem) throw new Error(problem);
+ const r = resolveRepo(repo as string);
+ const commitOid = await commitChangeset(r.repoDir, {
+ files: files as ChangesetFile[],
+ message: message as string,
+ author: { name: author_name as string, email: author_email as string },
+ });
+ return { commitOid };
+ },
+};
+
+interface SandboxRepoSpec {
+ repo: string;
+ files?: ChangesetFile[];
+}
+
+const runInSandbox: ToolDef = {
+ name: "run_in_sandbox",
+ description:
+ "Runs a command (e.g. a test suite) against one or more repos in a disposable, throwaway checkout -- never the real repo. Use this to verify your work BEFORE committing it, not after. Pass each repo you need (the one you're changing, plus any it depends on); give `files` only for the repo whose proposed-but-not-yet-committed content you want to test -- repos listed without `files` are checked out at their real current state, so a cross-repo dependency resolves the same way it would for real.",
+ parameters: {
+ type: "object",
+ properties: {
+ repos: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ repo: { type: "string" },
+ files: { type: "array", items: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
+ },
+ required: ["repo"],
+ },
+ },
+ command: { type: "string", description: "Shell command to run, e.g. \"node --test\"" },
+ cwd: { type: "string", description: "Which repo (by slug) to run the command in" },
+ },
+ required: ["repos", "command", "cwd"],
+ },
+ async execute({ repos, command, cwd }) {
+ const sandboxRoot = mkdtempSync(join(tmpdir(), "zz-sandbox-"));
+ const worktrees: { repoDir: string; worktreePath: string; shortName: string }[] = [];
+ try {
+ for (const spec of repos as SandboxRepoSpec[]) {
+ const r = resolveRepo(spec.repo);
+ const worktreePath = join(sandboxRoot, r.name);
+ execFileSync("git", ["-C", r.repoDir, "worktree", "add", "--detach", worktreePath, "HEAD"], { encoding: "utf8" });
+ worktrees.push({ repoDir: r.repoDir, worktreePath, shortName: r.name });
+ for (const f of spec.files ?? []) {
+ const dest = join(worktreePath, f.path);
+ mkdirSync(join(dest, ".."), { recursive: true });
+ writeFileSync(dest, f.content);
+ }
+ }
+ // Cross-repo dependencies resolve via a plain symlink (whether the
+ // package.json says "file:../x" or a version range -- no real
+ // registry exists for these packages either way) rather than
+ // requiring `npm install` to succeed against something that isn't
+ // actually published.
+ const shortNames = new Set(worktrees.map((w) => w.shortName));
+ for (const { worktreePath } of worktrees) {
+ const pkgPath = join(worktreePath, "package.json");
+ if (!existsSync(pkgPath)) continue;
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
+ const depNames = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
+ for (const dep of depNames) {
+ if (!shortNames.has(dep)) continue;
+ const other = worktrees.find((w) => w.shortName === dep)!;
+ const nodeModules = join(worktreePath, "node_modules");
+ mkdirSync(nodeModules, { recursive: true });
+ const linkPath = join(nodeModules, dep);
+ if (!existsSync(linkPath)) symlinkSync(other.worktreePath, linkPath);
+ }
+ }
+ const cwdRepo = resolveRepo(cwd as string);
+ const runDir = join(sandboxRoot, cwdRepo.name);
+ try {
+ const stdout = execFileSync("/bin/sh", ["-c", command as string], { cwd: runDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ return { exitCode: 0, stdout, stderr: "" };
+ } catch (err) {
+ const e = err as { status?: number; stdout?: string; stderr?: string };
+ return { exitCode: e.status ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? String(err) };
+ }
+ } finally {
+ for (const { repoDir, worktreePath } of worktrees) {
+ try {
+ execFileSync("git", ["-C", repoDir, "worktree", "remove", "--force", worktreePath]);
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ rmSync(sandboxRoot, { recursive: true, force: true });
+ }
+ },
+};
+
 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];
+export const CODE_TOOLS: ToolDef[] = [commitChangesetTool, runInSandbox];
+export const ALL_TOOLS: ToolDef[] = [...GIT_TOOLS, ...LADDER_LANE_TOOLS, ...CODE_TOOLS];