@zhn2605/topo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/topo.js CHANGED
@@ -923,8 +923,8 @@ function parseUnifiedDiff(text) {
923
923
  if (line.startsWith("diff --git ")) {
924
924
  flush();
925
925
  const both = line.slice("diff --git ".length);
926
- const path27 = unquote(both.slice(0, (both.length - 1) / 2));
927
- cur = { oldPath: path27, newPath: path27, status: "modified", oldRanges: [], newRanges: [] };
926
+ const path29 = unquote(both.slice(0, (both.length - 1) / 2));
927
+ cur = { oldPath: path29, newPath: path29, status: "modified", oldRanges: [], newRanges: [] };
928
928
  } else if (!cur) {
929
929
  } else if (line.startsWith("new file mode")) {
930
930
  cur.status = "added";
@@ -974,10 +974,10 @@ function getFileDiffs(repo, baseSha, headRef) {
974
974
  const diffs = parseUnifiedDiff(git(repo, args).toString());
975
975
  if (headRef) return diffs;
976
976
  const untracked = git(repo, ["ls-files", "--others", "--exclude-standard", "-z"]).toString().split("\0").filter((p) => p && !p.endsWith("/"));
977
- for (const path27 of untracked) {
978
- const n = lineCount(join(repo, path27));
977
+ for (const path29 of untracked) {
978
+ const n = lineCount(join(repo, path29));
979
979
  diffs.push({
980
- newPath: path27,
980
+ newPath: path29,
981
981
  status: "added",
982
982
  oldRanges: [],
983
983
  newRanges: n ? [{ startLine: 1, endLine: n }] : []
@@ -1016,10 +1016,10 @@ import fs3 from "node:fs";
1016
1016
  import os from "node:os";
1017
1017
  import path3 from "node:path";
1018
1018
  function requireSpec(arg) {
1019
- const str = /^(["'`])(.*)\1$/.exec(arg)?.[2];
1020
- if (str !== void 0) {
1021
- const relative2 = /^(\.\.?|@self)\//.test(str) ? str : void 0;
1022
- return { ...relative2 && { relative: relative2 }, tokens: str.split("/") };
1019
+ const str3 = /^(["'`])(.*)\1$/.exec(arg)?.[2];
1020
+ if (str3 !== void 0) {
1021
+ const relative2 = /^(\.\.?|@self)\//.test(str3) ? str3 : void 0;
1022
+ return { ...relative2 && { relative: relative2 }, tokens: str3.split("/") };
1023
1023
  }
1024
1024
  const tokens = (arg.match(/[A-Za-z_][\w-]*/g) ?? []).filter((t) => !LOOKUPS.has(t));
1025
1025
  const chain = /^script((?:\.Parent)*)((?:\.[A-Za-z_]\w*)+)$/.exec(arg.replace(/\s+/g, ""));
@@ -1789,8 +1789,8 @@ function calleeName(call) {
1789
1789
  if (ts2.isPropertyAccessExpression(c) && ts2.isIdentifier(c.name)) return c.name.text;
1790
1790
  return void 0;
1791
1791
  }
1792
- function segsOf(path27) {
1793
- return path27.split("/").filter(Boolean).map((s) => s.includes("*") || s.startsWith("[...") || s.startsWith("[[...") ? "*" : s).map((s) => /^[:{[]/.test(s) ? ":" : s);
1792
+ function segsOf(path29) {
1793
+ return path29.split("/").filter(Boolean).map((s) => s.includes("*") || s.startsWith("[...") || s.startsWith("[[...") ? "*" : s).map((s) => /^[:{[]/.test(s) ? ":" : s);
1794
1794
  }
1795
1795
  function urlPath(e) {
1796
1796
  let text;
@@ -1855,8 +1855,8 @@ function routeCall(r, call, site, target, handlerOf) {
1855
1855
  if (to) r.servers.push({ method: shape.method, segs: segsOf(shape.path), to });
1856
1856
  if (to !== void 0) return;
1857
1857
  }
1858
- const path27 = arg && urlPath(arg);
1859
- if (!path27) return;
1858
+ const path29 = arg && urlPath(arg);
1859
+ if (!path29) return;
1860
1860
  const m = name.toLowerCase();
1861
1861
  let method;
1862
1862
  if (ts2.isIdentifier(call.expression) && CLIENT_FNS.has(name)) method = fetchMethod(call);
@@ -1864,9 +1864,9 @@ function routeCall(r, call, site, target, handlerOf) {
1864
1864
  method = m.toUpperCase();
1865
1865
  else if (target && (target.kind === "function" || target.kind === "method")) method = "ANY";
1866
1866
  else return;
1867
- const segs = segsOf(path27);
1867
+ const segs = segsOf(path29);
1868
1868
  if (segs.some((s) => s !== ":" && s !== "*"))
1869
- r.clients.push({ ...site, method, segs, raw: path27 });
1869
+ r.clients.push({ ...site, method, segs, raw: path29 });
1870
1870
  }
1871
1871
  function serverLiteral(r, lit, to, prefix = false) {
1872
1872
  const m = isStr(lit) ? ROUTE_LITERAL.exec(lit.text) : null;
@@ -3471,8 +3471,8 @@ import path8 from "node:path";
3471
3471
  import { Language as Language3, Parser as Parser4 } from "web-tree-sitter";
3472
3472
  function docstring(body) {
3473
3473
  const first = body?.namedChildren[0];
3474
- const str = first?.type === "expression_statement" ? first.namedChildren[0] : void 0;
3475
- return str?.type === "string" ? stripQuotes(str.text) : void 0;
3474
+ const str3 = first?.type === "expression_statement" ? first.namedChildren[0] : void 0;
3475
+ return str3?.type === "string" ? stripQuotes(str3.text) : void 0;
3476
3476
  }
3477
3477
  function dotted(n) {
3478
3478
  if (!n) return void 0;
@@ -3690,7 +3690,7 @@ function buildGraph3(parsed) {
3690
3690
  roots,
3691
3691
  (to, line) => add(fileId(f.file), "imports", fileId(to), ["static"], 1, { line, inLoop: false })
3692
3692
  );
3693
- const inModule = (file, path27) => byFile.get(file)?.byQname.get(path27.join("."));
3693
+ const inModule = (file, path29) => byFile.get(file)?.byQname.get(path29.join("."));
3694
3694
  const resolve2 = (callee, fnScopes, classQ) => {
3695
3695
  const [head, ...rest] = callee;
3696
3696
  if (!head) return void 0;
@@ -4170,7 +4170,7 @@ function walk(dir, skip, want, out = []) {
4170
4170
  function references(config) {
4171
4171
  if (!fs11.existsSync(config)) return [];
4172
4172
  try {
4173
- const json2 = fs11.readFileSync(config, "utf8").replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
4173
+ const json2 = fs11.readFileSync(config, "utf8").replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str3) => str3 ?? "").replace(/,(\s*[}\]])/g, "$1");
4174
4174
  const refs = JSON.parse(json2).references ?? [];
4175
4175
  return refs.map((r) => {
4176
4176
  const p = path11.resolve(path11.dirname(config), r.path);
@@ -5914,42 +5914,314 @@ Options:
5914
5914
  }
5915
5915
  });
5916
5916
 
5917
+ // packages/cli/src/permissions.ts
5918
+ import fs14 from "node:fs";
5919
+ import path16 from "node:path";
5920
+ function parseSettings(raw) {
5921
+ const p = isRecord2(raw) ? raw.permissions : void 0;
5922
+ return {
5923
+ version: 1,
5924
+ permissions: PERMISSION_MODES.includes(p) ? p : DEFAULT_SETTINGS.permissions
5925
+ };
5926
+ }
5927
+ function readSettings(repo) {
5928
+ try {
5929
+ return parseSettings(JSON.parse(fs14.readFileSync(settingsFile(repo), "utf8")));
5930
+ } catch {
5931
+ return DEFAULT_SETTINGS;
5932
+ }
5933
+ }
5934
+ function writeSettings(repo, s) {
5935
+ writeJsonAtomic(settingsFile(repo), s);
5936
+ }
5937
+ function writeJsonAtomic(file, value) {
5938
+ fs14.mkdirSync(path16.dirname(file), { recursive: true });
5939
+ const tmp = `${file}.${process.pid}.tmp`;
5940
+ fs14.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
5941
+ `);
5942
+ fs14.renameSync(tmp, file);
5943
+ }
5944
+ function workerPermissionArgs(mode) {
5945
+ if (mode === "never") return ["--dangerously-skip-permissions"];
5946
+ const allowed = ["mcp__topo__*", ...mode === "safe" ? SAFE_RULES : []];
5947
+ return [
5948
+ "--permission-mode",
5949
+ "acceptEdits",
5950
+ "--allowedTools",
5951
+ allowed.join(","),
5952
+ "--permission-prompt-tool",
5953
+ "mcp__topo__permission_prompt"
5954
+ ];
5955
+ }
5956
+ function ruleFor(toolName, input) {
5957
+ if (toolName !== "Bash") return toolName;
5958
+ const cmd = isRecord2(input) && typeof input.command === "string" ? input.command.trim() : "";
5959
+ if (!cmd || COMPOUND.test(cmd)) return void 0;
5960
+ const words = cmd.split(/\s+/);
5961
+ const [first = "", second] = words;
5962
+ if (second === void 0) return `Bash(${first})`;
5963
+ if (second.startsWith("-")) return `Bash(${cmd})`;
5964
+ const prefix = SUBCOMMANDS.has(first) ? `${first} ${second}` : first;
5965
+ return `Bash(${prefix} *)`;
5966
+ }
5967
+ function ruleMatches(rule, toolName, input) {
5968
+ const m = /^([^(]+)(?:\((.*)\))?$/.exec(rule.trim());
5969
+ if (!m || m[1] !== toolName) return false;
5970
+ const pattern = m[2];
5971
+ if (pattern === void 0) return true;
5972
+ if (toolName !== "Bash") return false;
5973
+ const cmd = isRecord2(input) && typeof input.command === "string" ? input.command.trim() : "";
5974
+ if (!cmd || COMPOUND.test(cmd)) return false;
5975
+ const prefix = /^(.*?)(?::\*| \*)$/.exec(pattern)?.[1];
5976
+ if (prefix === void 0) return cmd === pattern;
5977
+ return cmd === prefix || cmd.startsWith(`${prefix} `);
5978
+ }
5979
+ function readLocalSettings(repo) {
5980
+ try {
5981
+ const raw = JSON.parse(fs14.readFileSync(localSettingsFile(repo), "utf8"));
5982
+ return isRecord2(raw) ? raw : {};
5983
+ } catch {
5984
+ return {};
5985
+ }
5986
+ }
5987
+ function readLocalAllow(repo) {
5988
+ const perms = readLocalSettings(repo).permissions;
5989
+ const allow = isRecord2(perms) ? perms.allow : void 0;
5990
+ return Array.isArray(allow) ? allow.filter((r) => typeof r === "string") : [];
5991
+ }
5992
+ function withAllowRule(doc, rule) {
5993
+ const perms = isRecord2(doc.permissions) ? doc.permissions : {};
5994
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
5995
+ if (allow.includes(rule)) return doc;
5996
+ return { ...doc, permissions: { ...perms, allow: [...allow, rule] } };
5997
+ }
5998
+ function addLocalAllowRule(repo, rule) {
5999
+ const file = localSettingsFile(repo);
6000
+ let doc = {};
6001
+ if (fs14.existsSync(file)) {
6002
+ const raw = JSON.parse(fs14.readFileSync(file, "utf8"));
6003
+ if (!isRecord2(raw)) throw new Error(`${file} isn't a JSON object; not changing it`);
6004
+ doc = raw;
6005
+ }
6006
+ writeJsonAtomic(file, withAllowRule(doc, rule));
6007
+ }
6008
+ var PERMISSION_MODES, DEFAULT_SETTINGS, SAFE_RULES, isRecord2, settingsFile, SUBCOMMANDS, COMPOUND, localSettingsFile;
6009
+ var init_permissions = __esm({
6010
+ "packages/cli/src/permissions.ts"() {
6011
+ "use strict";
6012
+ PERMISSION_MODES = ["safe", "ask", "never"];
6013
+ DEFAULT_SETTINGS = { version: 1, permissions: "safe" };
6014
+ SAFE_RULES = [
6015
+ "Bash(ls *)",
6016
+ "Bash(pwd)",
6017
+ "Bash(cat *)",
6018
+ "Bash(head *)",
6019
+ "Bash(tail *)",
6020
+ "Bash(wc *)",
6021
+ "Bash(grep *)",
6022
+ "Bash(rg *)",
6023
+ "Bash(find *)",
6024
+ "Bash(git status *)",
6025
+ "Bash(git diff *)",
6026
+ "Bash(git log *)",
6027
+ "Bash(git show *)",
6028
+ "Bash(git blame *)"
6029
+ ];
6030
+ isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6031
+ settingsFile = (repo) => path16.join(repo, ".topo", "settings.json");
6032
+ SUBCOMMANDS = /* @__PURE__ */ new Set(["git", "npm", "pnpm", "yarn", "bun", "npx", "cargo", "go", "docker"]);
6033
+ COMPOUND = /&&|\|\||[;|`<>]|\$\(/;
6034
+ localSettingsFile = (repo) => path16.join(repo, ".claude", "settings.local.json");
6035
+ }
6036
+ });
6037
+
6038
+ // packages/cli/src/adapters.ts
6039
+ var AGENT_IDS, mcpUrl, str, rec, claude, GEMINI_EDIT_TOOLS, gemini, codex, ADAPTERS;
6040
+ var init_adapters = __esm({
6041
+ "packages/cli/src/adapters.ts"() {
6042
+ "use strict";
6043
+ init_hook();
6044
+ init_permissions();
6045
+ AGENT_IDS = ["claude", "codex", "gemini"];
6046
+ mcpUrl = (port) => `http://127.0.0.1:${port}/mcp`;
6047
+ str = (v) => typeof v === "string" ? v : void 0;
6048
+ rec = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
6049
+ claude = {
6050
+ id: "claude",
6051
+ name: "Claude",
6052
+ bin: "claude",
6053
+ asksMidRun: true,
6054
+ resumes: true,
6055
+ worker: (t) => ({
6056
+ argv: [
6057
+ "-p",
6058
+ t.prompt,
6059
+ "--output-format",
6060
+ "stream-json",
6061
+ "--verbose",
6062
+ "--mcp-config",
6063
+ "{file:mcp}",
6064
+ "--settings",
6065
+ "{file:settings}",
6066
+ ...workerPermissionArgs(t.mode),
6067
+ // Without this, workers run on the user's default model (often the priciest one).
6068
+ "--model",
6069
+ process.env.TOPO_WORKER_MODEL ?? "sonnet",
6070
+ ...t.resume ? ["--resume", t.resume] : []
6071
+ ],
6072
+ json: {
6073
+ mcp: {
6074
+ mcpServers: {
6075
+ topo: { type: "http", url: mcpUrl(t.port), headers: { "x-topo-run": t.runId } }
6076
+ }
6077
+ },
6078
+ // Its edits come in through the same PostToolUse hook `topo hook` prints.
6079
+ settings: hookSettings(t.port)
6080
+ }
6081
+ }),
6082
+ parse: (o) => {
6083
+ if (o.type === "system" && o.subtype === "init" && str(o.session_id))
6084
+ return [{ kind: "started", session: o.session_id }];
6085
+ if (o.type === "result")
6086
+ return [
6087
+ {
6088
+ kind: "finished",
6089
+ failed: o.is_error === true,
6090
+ summary: str(o.result),
6091
+ costUsd: typeof o.total_cost_usd === "number" ? o.total_cost_usd : void 0
6092
+ }
6093
+ ];
6094
+ return [];
6095
+ }
6096
+ };
6097
+ GEMINI_EDIT_TOOLS = /* @__PURE__ */ new Set(["write_file", "replace", "edit"]);
6098
+ gemini = {
6099
+ id: "gemini",
6100
+ name: "Gemini",
6101
+ bin: "gemini",
6102
+ asksMidRun: false,
6103
+ resumes: false,
6104
+ // --resume takes "latest" or a list index, not a session id
6105
+ worker: (t) => ({
6106
+ argv: [
6107
+ "-p",
6108
+ t.prompt,
6109
+ "--output-format",
6110
+ "stream-json",
6111
+ "--skip-trust",
6112
+ "--approval-mode",
6113
+ t.mode === "never" ? "yolo" : "auto_edit",
6114
+ ...process.env.TOPO_GEMINI_MODEL ? ["--model", process.env.TOPO_GEMINI_MODEL] : []
6115
+ ],
6116
+ env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: "{file:settings}" },
6117
+ json: {
6118
+ settings: {
6119
+ mcpServers: {
6120
+ topo: { httpUrl: mcpUrl(t.port), headers: { "x-topo-run": t.runId }, trust: true }
6121
+ }
6122
+ }
6123
+ }
6124
+ }),
6125
+ parse: (o) => {
6126
+ if (o.type === "init" && str(o.session_id))
6127
+ return [{ kind: "started", session: o.session_id }];
6128
+ if (o.type === "message" && o.role === "assistant" && str(o.content))
6129
+ return [{ kind: "text", text: o.content, append: o.delta === true }];
6130
+ if (o.type === "tool_use" && GEMINI_EDIT_TOOLS.has(str(o.tool_name) ?? "")) {
6131
+ const file = str(rec(o.parameters).file_path);
6132
+ return file ? [{ kind: "edit", file }] : [];
6133
+ }
6134
+ if (o.type === "result") return [{ kind: "finished", failed: o.status !== "success" }];
6135
+ return [];
6136
+ }
6137
+ };
6138
+ codex = {
6139
+ id: "codex",
6140
+ name: "Codex",
6141
+ bin: "codex",
6142
+ asksMidRun: false,
6143
+ resumes: true,
6144
+ worker: (t) => {
6145
+ const opts = [
6146
+ "--json",
6147
+ "-c",
6148
+ `mcp_servers.topo.url=${JSON.stringify(mcpUrl(t.port))}`,
6149
+ "-c",
6150
+ `mcp_servers.topo.http_headers={ "x-topo-run" = ${JSON.stringify(t.runId)} }`,
6151
+ ...t.mode === "never" ? ["--dangerously-bypass-approvals-and-sandbox"] : ["-s", "workspace-write", "-c", 'approval_policy="never"'],
6152
+ ...process.env.TOPO_CODEX_MODEL ? ["-m", process.env.TOPO_CODEX_MODEL] : []
6153
+ ];
6154
+ return {
6155
+ argv: t.resume ? ["exec", "resume", ...opts, t.resume, t.prompt] : ["exec", ...opts, t.prompt]
6156
+ };
6157
+ },
6158
+ parse: (o) => {
6159
+ if (o.type === "thread.started" && str(o.thread_id))
6160
+ return [{ kind: "started", session: o.thread_id }];
6161
+ if (o.type === "item.completed") {
6162
+ const item = rec(o.item);
6163
+ if (item.type === "agent_message" && str(item.text))
6164
+ return [{ kind: "text", text: item.text }];
6165
+ if (item.type === "file_change" && Array.isArray(item.changes))
6166
+ return item.changes.flatMap((c) => {
6167
+ const file = str(rec(c).path);
6168
+ return file ? [{ kind: "edit", file }] : [];
6169
+ });
6170
+ }
6171
+ if (o.type === "turn.completed") return [{ kind: "finished", failed: false }];
6172
+ if (o.type === "turn.failed")
6173
+ return [{ kind: "finished", failed: true, summary: str(rec(o.error).message) }];
6174
+ return [];
6175
+ }
6176
+ };
6177
+ ADAPTERS = { claude, codex, gemini };
6178
+ }
6179
+ });
6180
+
5917
6181
  // packages/cli/src/agents.ts
5918
6182
  import { spawn as nodeSpawn } from "node:child_process";
5919
- import fs14 from "node:fs";
6183
+ import fs15 from "node:fs";
5920
6184
  import os2 from "node:os";
5921
- import path16 from "node:path";
6185
+ import path17 from "node:path";
6186
+ function describeContext(c) {
6187
+ const roots = c.roots ?? [];
6188
+ const what = c.mode === "review" ? "the review of the changes" : c.mode === "flow" ? "a flow" : "a map";
6189
+ const depth = c.depth === void 0 ? "" : c.depth === 0 ? ", just those blocks" : `, with what they call and what calls them ${c.depth} hop(s) out`;
6190
+ const more = roots.length > MAX_CONTEXT_ROOTS ? ` and ${roots.length - MAX_CONTEXT_ROOTS} more` : "";
6191
+ const sel = c.selected?.length ? ` Selected: ${c.selected.join(", ")}.` : "";
6192
+ return roots.length ? `The human's map right now: ${what}, rooted at ${roots.length} block(s)${depth}: ${roots.slice(0, MAX_CONTEXT_ROOTS).join(", ")}${more}.${sel}` : `The human's map right now: ${what}, empty.${sel}`;
6193
+ }
5922
6194
  function resolveBin(bin) {
5923
6195
  const tryExec = (p) => {
5924
6196
  try {
5925
- fs14.accessSync(p, fs14.constants.X_OK);
6197
+ fs15.accessSync(p, fs15.constants.X_OK);
5926
6198
  return p;
5927
6199
  } catch {
5928
6200
  return void 0;
5929
6201
  }
5930
6202
  };
5931
- if (bin.includes(path16.sep)) return tryExec(bin);
5932
- const dirs = (process.env.PATH ?? "").split(path16.delimiter).filter(Boolean);
6203
+ if (bin.includes(path17.sep)) return tryExec(bin);
6204
+ const dirs = (process.env.PATH ?? "").split(path17.delimiter).filter(Boolean);
5933
6205
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD").split(";") : [""];
5934
6206
  for (const dir of dirs) {
5935
6207
  for (const ext of exts) {
5936
- const hit = tryExec(path16.join(dir, bin + ext));
6208
+ const hit = tryExec(path17.join(dir, bin + ext));
5937
6209
  if (hit) return hit;
5938
6210
  }
5939
6211
  }
5940
6212
  return void 0;
5941
6213
  }
5942
6214
  function writeTempJson(prefix, value) {
5943
- const file = path16.join(
6215
+ const file = path17.join(
5944
6216
  os2.tmpdir(),
5945
6217
  `${prefix}-${process.pid}-${Math.random().toString(36).slice(2)}.json`
5946
6218
  );
5947
- fs14.writeFileSync(file, JSON.stringify(value));
6219
+ fs15.writeFileSync(file, JSON.stringify(value));
5948
6220
  return file;
5949
6221
  }
5950
6222
  function rmQuiet(file) {
5951
6223
  try {
5952
- fs14.unlinkSync(file);
6224
+ fs15.unlinkSync(file);
5953
6225
  } catch {
5954
6226
  }
5955
6227
  }
@@ -5979,6 +6251,11 @@ function createAgentManager(opts) {
5979
6251
  const spawn3 = opts.spawn ?? nodeSpawn;
5980
6252
  const claudeBin = opts.claudeBin ?? "claude";
5981
6253
  const resolved = resolveBin(claudeBin);
6254
+ const installed = /* @__PURE__ */ new Map();
6255
+ for (const id of AGENT_IDS) {
6256
+ const bin = id === "claude" ? resolved : resolveBin(opts.bins?.[id] ?? ADAPTERS[id].bin);
6257
+ if (bin) installed.set(id, bin);
6258
+ }
5982
6259
  const runs = /* @__PURE__ */ new Map();
5983
6260
  const children = /* @__PURE__ */ new Map();
5984
6261
  let runSeq = 0;
@@ -6001,13 +6278,14 @@ function createAgentManager(opts) {
6001
6278
  opts.bus.subscribe((e) => {
6002
6279
  if ((e.type === "explain" || e.type === "focus" || e.type === "lens") && e.command)
6003
6280
  updateExchange(e.command, () => ({ view: e }));
6281
+ else if (e.type === "ask" && e.command) updateExchange(e.command, () => ({ ask: e.questions }));
6004
6282
  });
6005
6283
  function setTerritoryStatus(territoryId, patch) {
6006
6284
  const territories = opts.getTerritories();
6007
6285
  const next = territories.map((t) => t.id === territoryId ? { ...t, ...patch } : t);
6008
6286
  opts.saveTerritories(next);
6009
6287
  }
6010
- function launch(territoryId, reply) {
6288
+ function launch(territoryId, reply, agent) {
6011
6289
  const territory = opts.getTerritories().find((t) => t.id === territoryId);
6012
6290
  if (!territory) throw new Error(`no territory "${territoryId}"`);
6013
6291
  const territoryRuns = [...runs.values()].filter((r) => r.territoryId === territoryId);
@@ -6017,99 +6295,97 @@ function createAgentManager(opts) {
6017
6295
  if (!reply && !territory.note?.trim()) {
6018
6296
  throw new Error(`"${territoryId}" has no note; write the task before launching`);
6019
6297
  }
6020
- const resumeFrom = reply ? territoryRuns.findLast((r) => r.agent?.startsWith("claude:"))?.agent?.slice("claude:".length) : void 0;
6298
+ const wanted = agent ?? territory.runner;
6299
+ if (wanted !== void 0 && !AGENT_IDS.includes(wanted)) {
6300
+ throw new Error(`unknown agent "${wanted}" (one of ${AGENT_IDS.join(", ")})`);
6301
+ }
6302
+ const adapter = wanted ? ADAPTERS[wanted] : ADAPTERS[[...installed.keys()][0] ?? "claude"];
6303
+ const bin = adapter && installed.get(adapter.id);
6304
+ if (!adapter || !bin) throw new Error(`no \`${adapter?.bin ?? "claude"}\` CLI found on PATH`);
6305
+ const prefix = `${adapter.id}:`;
6306
+ const resumeFrom = reply ? territoryRuns.findLast((r) => r.agent?.startsWith(prefix))?.agent?.slice(prefix.length) : void 0;
6307
+ if (reply && !adapter.resumes) {
6308
+ throw new Error(`${adapter.name} can't continue an earlier run; launch it again instead`);
6309
+ }
6021
6310
  if (reply && !resumeFrom) throw new Error(`"${territoryId}" has no earlier run to continue`);
6022
- if (!resolved) throw new Error("no `claude` CLI found on PATH");
6311
+ if (agent && territory.runner !== adapter.id) {
6312
+ setTerritoryStatus(territoryId, { runner: adapter.id });
6313
+ }
6023
6314
  const id = `run-${++runSeq}`;
6024
- const mcpConfig = writeTempJson("topo-mcp", {
6025
- mcpServers: {
6026
- topo: {
6027
- type: "http",
6028
- url: `http://127.0.0.1:${opts.port()}/mcp`,
6029
- headers: { "x-topo-run": id }
6030
- }
6031
- }
6032
- });
6033
- const settings = writeTempJson("topo-settings", hookSettings(opts.port()));
6034
6315
  const prompt = reply ?? [
6035
6316
  territory.label,
6036
6317
  territory.note,
6037
6318
  `Call get_territory ${territory.id} first. Stay inside it; if you need blocks outside, call request_territory. After changing a block, call report_change with one sentence on why.`
6038
6319
  ].filter((s) => !!s).join("\n\n");
6320
+ const spec = adapter.worker({
6321
+ runId: id,
6322
+ prompt,
6323
+ port: opts.port(),
6324
+ mode: opts.permissionMode?.() ?? "safe",
6325
+ resume: resumeFrom
6326
+ });
6327
+ const files = new Map(
6328
+ Object.entries(spec.json ?? {}).map(([k, v]) => [k, writeTempJson(`topo-${k}`, v)])
6329
+ );
6330
+ const fill = (s) => s.replace(/^\{file:(\w+)\}$/, (_, k) => files.get(k) ?? s);
6039
6331
  const run8 = {
6040
6332
  id,
6041
6333
  role: "worker",
6042
6334
  territoryId,
6043
- label: `Claude \xB7 ${territory.label}`,
6335
+ label: `${adapter.name} \xB7 ${territory.label}`,
6044
6336
  status: "starting",
6045
6337
  startedAt: Date.now()
6046
6338
  };
6047
6339
  runs.set(id, run8);
6048
- const child = spawn3(
6049
- resolved,
6050
- [
6051
- "-p",
6052
- prompt,
6053
- "--output-format",
6054
- "stream-json",
6055
- "--verbose",
6056
- "--mcp-config",
6057
- mcpConfig,
6058
- "--settings",
6059
- settings,
6060
- ...opts.skipPermissions ? ["--dangerously-skip-permissions"] : [
6061
- "--permission-mode",
6062
- "acceptEdits",
6063
- // Topo's own tools never prompt (otherwise every get_territory would wait in the
6064
- // inbox); everything acceptEdits doesn't cover goes to the inbox via the prompt tool.
6065
- "--allowedTools",
6066
- "mcp__topo__*",
6067
- "--permission-prompt-tool",
6068
- "mcp__topo__permission_prompt"
6069
- ],
6070
- // Without this, workers run on the user's default model (often the priciest one).
6071
- "--model",
6072
- process.env.TOPO_WORKER_MODEL ?? "sonnet",
6073
- ...resumeFrom ? ["--resume", resumeFrom] : []
6074
- ],
6075
- { cwd: opts.repo, stdio: ["ignore", "pipe", "pipe"] }
6076
- );
6340
+ const child = spawn3(bin, spec.argv.map(fill), {
6341
+ cwd: opts.repo,
6342
+ stdio: ["ignore", "pipe", "pipe"],
6343
+ ...spec.env && {
6344
+ env: {
6345
+ ...process.env,
6346
+ ...Object.fromEntries(Object.entries(spec.env).map(([k, v]) => [k, fill(v)]))
6347
+ }
6348
+ }
6349
+ });
6077
6350
  children.set(id, child);
6078
6351
  let gotResult = false;
6352
+ let summary;
6079
6353
  const cleanup = () => {
6080
6354
  children.delete(id);
6081
- rmQuiet(mcpConfig);
6082
- rmQuiet(settings);
6355
+ for (const f of files.values()) rmQuiet(f);
6083
6356
  };
6084
6357
  child.stdout?.on(
6085
6358
  "data",
6086
6359
  makeLineFeed((obj) => {
6087
- const type = obj.type;
6088
- if (type === "system" && obj.subtype === "init") {
6089
- const init = obj;
6090
- run8.agent = `claude:${init.session_id}`;
6091
- run8.status = "running";
6092
- setTerritoryStatus(territoryId, { agent: run8.agent, status: "active" });
6093
- emitRuns();
6094
- } else if (type === "result") {
6095
- gotResult = true;
6096
- const result = obj;
6097
- run8.status = result.is_error ? "failed" : "done";
6098
- run8.summary = result.result;
6099
- run8.costUsd = result.total_cost_usd;
6100
- run8.endedAt = Date.now();
6101
- setTerritoryStatus(territoryId, { status: "review" });
6102
- opts.inbox.add({
6103
- kind: "finished",
6104
- title: `${territory.label}: ${run8.status === "failed" ? "failed" : "finished"}`,
6105
- detail: result.result,
6106
- agent: run8.agent,
6107
- label: run8.label,
6108
- runId: id,
6109
- territoryId,
6110
- nodeIds: territory.nodeIds
6111
- });
6112
- emitRuns();
6360
+ for (const ev of adapter.parse(obj)) {
6361
+ if (ev.kind === "started") {
6362
+ run8.agent = `${prefix}${ev.session}`;
6363
+ run8.status = "running";
6364
+ setTerritoryStatus(territoryId, { agent: run8.agent, status: "active" });
6365
+ emitRuns();
6366
+ } else if (ev.kind === "text") {
6367
+ summary = ev.append ? (summary ?? "") + ev.text : ev.text;
6368
+ } else if (ev.kind === "edit") {
6369
+ if (run8.agent) opts.onEdit?.(run8.agent, ev.file);
6370
+ } else if (ev.kind === "finished") {
6371
+ gotResult = true;
6372
+ run8.status = ev.failed ? "failed" : "done";
6373
+ run8.summary = ev.summary ?? summary;
6374
+ run8.costUsd = ev.costUsd;
6375
+ run8.endedAt = Date.now();
6376
+ setTerritoryStatus(territoryId, { status: "review" });
6377
+ opts.inbox.add({
6378
+ kind: "finished",
6379
+ title: `${territory.label}: ${run8.status === "failed" ? "failed" : "finished"}`,
6380
+ detail: run8.summary,
6381
+ agent: run8.agent,
6382
+ label: run8.label,
6383
+ runId: id,
6384
+ territoryId,
6385
+ nodeIds: territory.nodeIds
6386
+ });
6387
+ emitRuns();
6388
+ }
6113
6389
  }
6114
6390
  })
6115
6391
  );
@@ -6146,7 +6422,7 @@ function createAgentManager(opts) {
6146
6422
  const id = `cmd-${++cmdSeq}`;
6147
6423
  const contextLine = context ? `
6148
6424
 
6149
- Context: ${JSON.stringify(context)}` : "";
6425
+ ${describeContext(context)}` : "";
6150
6426
  exchanges = [
6151
6427
  ...exchanges.slice(-(MAX_EXCHANGES - 1)),
6152
6428
  { id, text, status: "running", tools: [], at: Date.now() }
@@ -6248,8 +6524,17 @@ Context: ${JSON.stringify(context)}` : "";
6248
6524
  }
6249
6525
  return {
6250
6526
  get available() {
6527
+ return installed.size > 0;
6528
+ },
6529
+ get director() {
6251
6530
  return resolved !== void 0;
6252
6531
  },
6532
+ agents: () => [...installed.keys()].map((id) => ({
6533
+ id,
6534
+ name: ADAPTERS[id].name,
6535
+ asksMidRun: ADAPTERS[id].asksMidRun,
6536
+ resumes: ADAPTERS[id].resumes
6537
+ })),
6253
6538
  list: () => [...runs.values()],
6254
6539
  get: (runId) => runs.get(runId),
6255
6540
  launch,
@@ -6259,20 +6544,25 @@ Context: ${JSON.stringify(context)}` : "";
6259
6544
  newConversation
6260
6545
  };
6261
6546
  }
6262
- var DIRECTOR_SYSTEM_PROMPT, MAX_EXCHANGES, isActive;
6547
+ var DIRECTOR_SYSTEM_PROMPT, MAX_EXCHANGES, MAX_CONTEXT_ROOTS, isActive;
6263
6548
  var init_agents = __esm({
6264
6549
  async "packages/cli/src/agents.ts"() {
6265
6550
  "use strict";
6266
6551
  await init_src();
6267
- init_hook();
6552
+ init_adapters();
6268
6553
  DIRECTOR_SYSTEM_PROMPT = [
6269
6554
  "You answer the human's questions about this codebase and drive the Topo map for them, through the topo tools only. You never edit code.",
6270
- "For a question like how does X work, where is X, or what happens when Y: trace it with find, callees, callers and get_block (read the source of the blocks that matter), then call explain once with 3-8 steps in execution order. Pick only blocks that matter to the question; leave out UI, logging and plumbing that merely touches it. If explain reports unresolved steps, fix them and call it again.",
6555
+ "For a question like how does X work, where is X, or what happens when Y: trace it with find, callees, callers and get_block (read the source of the blocks that matter), then call explain once with the steps in execution order. Size it to the question: about 5 steps for the gist, every step that matters when asked for all of it or the details. Pick only blocks that matter to the question; leave out UI, logging and plumbing that merely touches it. If explain reports unresolved steps, fix them and call it again.",
6556
+ "Each message ends with a line saying what the human's map shows right now. For walk me through this, the current map, these changes, or what changed: the blocks are that map's roots (for every change instead, call view with no roots and depth 1 and use its roots). Read them with get_block, then call explain with them in run order (callers before what they call). Each step's text is 2-4 sentences: what the block does, and for a changed block what changed there and why it matters. A tour or walkthrough request always ends in explain, never in only a view change.",
6271
6557
  "For a request to change the view (a lens, a flow, focusing something), territories, agents or the inbox: use those tools and do it.",
6558
+ "For a request to change the code (fix, add, rename, refactor, remove): the request is the go-ahead, so hand it to a worker without asking. Find the blocks it touches, create_territory with a short label, those blocks, and a note that states the task in full (the worker sees only the note and its blocks), then launch_agent on it. Reply in a line or two: what the worker will do, and that its edits will show on the map. Ask first (with ask_user) only when the request is too vague to write the note.",
6559
+ "To plan a change (plan a change to X, how would I add Y): trace the blocks involved, then call explain with the plan as steps on the blocks it touches (what changes in each and why), and create_territory for each piece of work with a note that states it in full. Use ask_user where the plan forks. Don't launch_agent until the human says go.",
6560
+ `When a request is ambiguous in a way that changes what you'd do (how many agents, which territories to create, how long a walkthrough, which of several areas), call ask_user with concrete options and end your turn; the answer comes as the next message. Don't ask when a sensible default exists, and never ask for a go-ahead on a change already requested. Never end a reply by offering choices in prose ("I can do A, or B. Which?"): put them in ask_user so the human can click one.`,
6272
6561
  "To sort the code into layers (or regions, areas, parts): call get_layers, read what the files hold, then set_layers with 3-8 layers named by what the code does, so every source file lands in one. Say what each layer is in a line.",
6273
6562
  "Then reply in plain prose: a few sentences, or a numbered list that mirrors the steps. No headings, no tables, no preamble about which tools you called."
6274
6563
  ].join("\n\n");
6275
6564
  MAX_EXCHANGES = 50;
6565
+ MAX_CONTEXT_ROOTS = 60;
6276
6566
  isActive = (status) => status === "starting" || status === "running";
6277
6567
  }
6278
6568
  });
@@ -6314,7 +6604,7 @@ var init_attribution = __esm({
6314
6604
 
6315
6605
  // packages/cli/src/editor.ts
6316
6606
  import { spawn } from "node:child_process";
6317
- import path17 from "node:path";
6607
+ import path18 from "node:path";
6318
6608
  function editorTemplate(flag) {
6319
6609
  return flag ?? process.env.TOPO_EDITOR ?? DEFAULT_EDITOR;
6320
6610
  }
@@ -6343,10 +6633,10 @@ function splitTemplate(template) {
6343
6633
  return out;
6344
6634
  }
6345
6635
  function resolveInRepo(repo, file) {
6346
- const root = path17.resolve(repo);
6347
- const abs = path17.resolve(root, file);
6348
- const rel = path17.relative(root, abs);
6349
- if (rel === "" || rel.startsWith("..") || path17.isAbsolute(rel)) {
6636
+ const root = path18.resolve(repo);
6637
+ const abs = path18.resolve(root, file);
6638
+ const rel = path18.relative(root, abs);
6639
+ if (rel === "" || rel.startsWith("..") || path18.isAbsolute(rel)) {
6350
6640
  throw new Error(`path outside repository: ${file}`);
6351
6641
  }
6352
6642
  return abs;
@@ -6408,6 +6698,18 @@ function createInbox(bus, onDecision) {
6408
6698
  return {
6409
6699
  list: () => items,
6410
6700
  add(input) {
6701
+ if (input.kind === "out_of_territory" && input.agent !== void 0) {
6702
+ const idx = items.findIndex(
6703
+ (it) => it.kind === input.kind && it.agent === input.agent && it.status === "open"
6704
+ );
6705
+ if (idx !== -1) {
6706
+ const [prev] = items.splice(idx, 1);
6707
+ const item2 = { ...prev, ...input, at: Date.now() };
6708
+ items.unshift(item2);
6709
+ bus.emit({ type: "inbox", item: item2 });
6710
+ return item2;
6711
+ }
6712
+ }
6411
6713
  const item = {
6412
6714
  ...input,
6413
6715
  id: `inbox-${++counter}`,
@@ -6550,6 +6852,72 @@ var init_conflicts = __esm({
6550
6852
  }
6551
6853
  });
6552
6854
 
6855
+ // packages/cli/src/reviews.ts
6856
+ import fs16 from "node:fs";
6857
+ import path19 from "node:path";
6858
+ function readDoc(repo) {
6859
+ try {
6860
+ const doc = JSON.parse(fs16.readFileSync(reviewsFile(repo), "utf8"));
6861
+ if (doc?.version === 1 && doc.reviews && typeof doc.reviews === "object") return doc;
6862
+ } catch {
6863
+ }
6864
+ return { version: 1, reviews: {} };
6865
+ }
6866
+ function readReviews(repo, key) {
6867
+ const saved = readDoc(repo).reviews[key];
6868
+ if (!saved || typeof saved !== "object") return /* @__PURE__ */ new Map();
6869
+ return new Map(Object.entries(saved).filter(([, r]) => isReview(r)));
6870
+ }
6871
+ function writeReviews(repo, key, reviews) {
6872
+ const doc = readDoc(repo);
6873
+ delete doc.reviews[key];
6874
+ if (reviews.size) doc.reviews[key] = Object.fromEntries(reviews);
6875
+ for (const old of Object.keys(doc.reviews).slice(0, -KEEP_REVIEWS)) delete doc.reviews[old];
6876
+ const file = reviewsFile(repo);
6877
+ fs16.mkdirSync(path19.dirname(file), { recursive: true });
6878
+ const ignore = path19.join(path19.dirname(file), ".gitignore");
6879
+ const ignored = fs16.existsSync(ignore) ? fs16.readFileSync(ignore, "utf8") : "";
6880
+ if (!/^reviews\.json$/m.test(ignored))
6881
+ fs16.appendFileSync(ignore, `${ignored && !ignored.endsWith("\n") ? "\n" : ""}reviews.json
6882
+ `);
6883
+ const tmp = `${file}.${process.pid}.tmp`;
6884
+ fs16.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
6885
+ `);
6886
+ fs16.renameSync(tmp, file);
6887
+ }
6888
+ function fingerprint(change, head) {
6889
+ return `${change.changeType}:${head?.bodyHash ?? ""}`;
6890
+ }
6891
+ function currentMark(mark, change, head) {
6892
+ if (!mark) return { review: "pending" };
6893
+ if (mark.decision === "viewed" && mark.fp !== void 0 && mark.fp !== fingerprint(change, head))
6894
+ return { review: "pending", stale: true };
6895
+ return { review: mark.decision, ...mark.note && { note: mark.note } };
6896
+ }
6897
+ function askTerritory(agent, territories) {
6898
+ return agent && agent !== "you" ? territories.find((t) => t.agent === agent)?.id : void 0;
6899
+ }
6900
+ function askReply(asks) {
6901
+ return [
6902
+ "I reviewed your change in Topo. Please make these changes:",
6903
+ ...asks.map((a) => `- ${a.nodeId}: ${a.note ?? "needs another look"}`),
6904
+ "After each one, call report_change with one sentence on what you did."
6905
+ ].join("\n");
6906
+ }
6907
+ var KEEP_REVIEWS, reviewKey, reviewsFile, isReview;
6908
+ var init_reviews = __esm({
6909
+ "packages/cli/src/reviews.ts"() {
6910
+ "use strict";
6911
+ KEEP_REVIEWS = 20;
6912
+ reviewKey = (cs) => `${cs.baseSha}..${cs.headRef}`;
6913
+ reviewsFile = (repo) => path19.join(repo, ".topo", "reviews.json");
6914
+ isReview = (r) => {
6915
+ const x = r;
6916
+ return !!x && (x.decision === "viewed" || x.decision === "changes_requested") && (x.note === void 0 || typeof x.note === "string") && (x.fp === void 0 || typeof x.fp === "string");
6917
+ };
6918
+ }
6919
+ });
6920
+
6553
6921
  // packages/cli/src/live.ts
6554
6922
  function createEventBus() {
6555
6923
  const listeners = /* @__PURE__ */ new Set();
@@ -6604,8 +6972,19 @@ function labelFor(attribution, agent) {
6604
6972
  }
6605
6973
  function annotateChangeSet(changeSet, opts) {
6606
6974
  const blocks = changeSet.blocks.map((raw) => {
6607
- const verdict = opts.tracking.reviews.get(raw.nodeId);
6608
- const b = verdict ? { ...raw, review: verdict.decision, ...verdict.note && { reviewNote: verdict.note } } : raw;
6975
+ const mark = currentMark(
6976
+ opts.tracking.reviews.get(raw.nodeId),
6977
+ raw,
6978
+ opts.index.byId.get(raw.nodeId)
6979
+ );
6980
+ const agentTerritory = askTerritory(opts.tracking.blockAgent.get(raw.nodeId), opts.territories);
6981
+ const b = {
6982
+ ...raw,
6983
+ review: mark.review,
6984
+ ...mark.note && { reviewNote: mark.note },
6985
+ ...mark.stale && { viewedStale: true },
6986
+ ...agentTerritory && { agentTerritory }
6987
+ };
6609
6988
  const rationale = opts.tracking.rationale.get(b.nodeId);
6610
6989
  const agent = opts.tracking.blockAgent.get(b.nodeId);
6611
6990
  const territory = agent ? opts.territories.find((t) => t.agent === agent) : void 0;
@@ -6637,13 +7016,17 @@ function createLive(opts) {
6637
7016
  const territory = territories.find(
6638
7017
  (t) => t.agent === activity.agent || activity.session !== void 0 && t.agent === activity.session
6639
7018
  );
7019
+ const open = opts.inbox.list().find(
7020
+ (it) => it.kind === "out_of_territory" && it.agent === activity.agent && it.status === "open"
7021
+ );
7022
+ const all = [.../* @__PURE__ */ new Set([...open?.nodeIds ?? [], ...outOfTerritory])];
6640
7023
  opts.inbox.add({
6641
7024
  kind: "out_of_territory",
6642
- title: `${ownLabel} edited ${outOfTerritory.length} block${outOfTerritory.length === 1 ? "" : "s"} outside ${territory?.label ?? "its territory"}`,
7025
+ title: `${ownLabel} edited ${all.length} block${all.length === 1 ? "" : "s"} outside ${territory?.label ?? "its territory"}`,
6643
7026
  agent: activity.agent,
6644
7027
  label: activity.label,
6645
7028
  territoryId: territory?.id,
6646
- nodeIds: outOfTerritory
7029
+ nodeIds: all
6647
7030
  });
6648
7031
  }
6649
7032
  const changed = nodeIds.map((nodeId2) => ({
@@ -6737,12 +7120,13 @@ var init_live2 = __esm({
6737
7120
  await init_src();
6738
7121
  await init_attribution();
6739
7122
  await init_conflicts();
7123
+ init_reviews();
6740
7124
  }
6741
7125
  });
6742
7126
 
6743
7127
  // packages/cli/src/mcp.ts
6744
- import fs15 from "node:fs";
6745
- import path18 from "node:path";
7128
+ import fs17 from "node:fs";
7129
+ import path20 from "node:path";
6746
7130
  function qualifiedName(n) {
6747
7131
  const i = n.id.indexOf("#");
6748
7132
  return i === -1 ? n.name : n.id.slice(i + 1);
@@ -6805,6 +7189,38 @@ function requireString(args, key) {
6805
7189
  if (typeof v !== "string" || !v) throw new Error(`missing required "${key}"`);
6806
7190
  return v;
6807
7191
  }
7192
+ function parseQuestions(raw) {
7193
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > 4)
7194
+ throw new Error('"questions" must hold 1-4 {question, header?, options, multiSelect?} items');
7195
+ const problems = [];
7196
+ const questions = raw.map((q, i) => {
7197
+ const at = `question ${i + 1}`;
7198
+ const r = isRecord3(q) ? q : {};
7199
+ const question = str2(r.question);
7200
+ const header = str2(r.header);
7201
+ const opts = Array.isArray(r.options) ? r.options : [];
7202
+ if (!question) problems.push(`${at}: missing "question"`);
7203
+ if (header.length > 12) problems.push(`${at}: "header" is over 12 characters`);
7204
+ if (opts.length < 2 || opts.length > 4) problems.push(`${at}: give 2-4 options`);
7205
+ const options = opts.map((o, j) => {
7206
+ const label = str2(isRecord3(o) ? o.label : void 0);
7207
+ const description = str2(isRecord3(o) ? o.description : void 0);
7208
+ if (!label) problems.push(`${at}, option ${j + 1}: missing "label"`);
7209
+ return description ? { label, description } : { label };
7210
+ });
7211
+ if (new Set(options.map((o) => o.label)).size < options.length)
7212
+ problems.push(`${at}: option labels repeat`);
7213
+ return {
7214
+ question,
7215
+ ...header && { header },
7216
+ options,
7217
+ ...r.multiSelect === true && { multiSelect: true }
7218
+ };
7219
+ });
7220
+ if (problems.length)
7221
+ throw new Error(["nothing asked; fix these and call ask_user again:", ...problems].join("\n"));
7222
+ return questions;
7223
+ }
6808
7224
  function isToolAllowed(name, ctx) {
6809
7225
  const tool = TOOLS[name];
6810
7226
  if (!tool) return false;
@@ -6886,7 +7302,7 @@ function shapeViewTool(deps, args, ctx) {
6886
7302
  label,
6887
7303
  ...typeof args.mode === "string" && { mode: args.mode },
6888
7304
  ...roots && { roots },
6889
- view: isRecord2(args.view) ? args.view : {}
7305
+ view: isRecord3(args.view) ? args.view : {}
6890
7306
  };
6891
7307
  const problem = lensProblem(candidate);
6892
7308
  if (problem) throw new Error(`invalid lens: ${problem}`);
@@ -6911,25 +7327,25 @@ function shapeViewTool(deps, args, ctx) {
6911
7327
  return `proposed lens "${name}"${savedNote}${skipNote}; the human will approve or deny it`;
6912
7328
  }
6913
7329
  function saveLensToFile(repo, lens) {
6914
- const file = path18.join(repo, ".topo", "lenses.json");
7330
+ const file = path20.join(repo, ".topo", "lenses.json");
6915
7331
  let existing = [];
6916
7332
  try {
6917
- const raw = JSON.parse(fs15.readFileSync(file, "utf8"));
7333
+ const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
6918
7334
  const list = Array.isArray(raw) ? raw : raw?.lenses;
6919
7335
  if (Array.isArray(list)) existing = list;
6920
7336
  } catch {
6921
7337
  }
6922
7338
  const byName = new Map(existing.map((l) => [l.name, l]));
6923
7339
  byName.set(lens.name, lens);
6924
- fs15.mkdirSync(path18.dirname(file), { recursive: true });
7340
+ fs17.mkdirSync(path20.dirname(file), { recursive: true });
6925
7341
  const tmp = `${file}.${process.pid}.tmp`;
6926
- fs15.writeFileSync(tmp, `${JSON.stringify({ lenses: [...byName.values()] }, null, 2)}
7342
+ fs17.writeFileSync(tmp, `${JSON.stringify({ lenses: [...byName.values()] }, null, 2)}
6927
7343
  `);
6928
- fs15.renameSync(tmp, file);
7344
+ fs17.renameSync(tmp, file);
6929
7345
  }
6930
7346
  async function callTool(deps, params, ctx) {
6931
7347
  const name = params.name;
6932
- const args = isRecord2(params.arguments) ? params.arguments : {};
7348
+ const args = isRecord3(params.arguments) ? params.arguments : {};
6933
7349
  if (typeof name !== "string" || !TOOLS[name]) {
6934
7350
  return { content: [{ type: "text", text: `unknown tool "${String(name)}"` }], isError: true };
6935
7351
  }
@@ -6954,13 +7370,13 @@ function createMcpHandler(deps) {
6954
7370
  } catch {
6955
7371
  return { status: 200, body: rpcError(null, -32700, "Parse error") };
6956
7372
  }
6957
- if (!isRecord2(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
6958
- const id2 = isRecord2(msg) && typeof msg.id !== "undefined" ? msg.id : null;
7373
+ if (!isRecord3(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
7374
+ const id2 = isRecord3(msg) && typeof msg.id !== "undefined" ? msg.id : null;
6959
7375
  return { status: 200, body: rpcError(id2, -32600, "Invalid Request") };
6960
7376
  }
6961
7377
  if (!("id" in msg)) return { status: 202 };
6962
7378
  const id = msg.id;
6963
- const params = isRecord2(msg.params) ? msg.params : {};
7379
+ const params = isRecord3(msg.params) ? msg.params : {};
6964
7380
  switch (msg.method) {
6965
7381
  case "ping":
6966
7382
  return { status: 200, body: rpcResult(id, {}) };
@@ -6997,21 +7413,23 @@ function createMcpHandler(deps) {
6997
7413
  }
6998
7414
  };
6999
7415
  }
7000
- var PROTOCOL_VERSIONS, DEFAULT_PROTOCOL_VERSION, INSTRUCTIONS, isRecord2, isStrArr, isDirection, isStatus, MAX_LAYER_FILES, withCommand, MAX_STEPS, TOOLS, rpcResult, rpcError;
7416
+ var PROTOCOL_VERSIONS, DEFAULT_PROTOCOL_VERSION, INSTRUCTIONS, isRecord3, isStrArr, isDirection, isStatus, str2, MAX_LAYER_FILES, withCommand, MAX_STEPS, TOOLS, rpcResult, rpcError;
7001
7417
  var init_mcp = __esm({
7002
7418
  async "packages/cli/src/mcp.ts"() {
7003
7419
  "use strict";
7004
7420
  await init_src();
7421
+ init_permissions();
7005
7422
  PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
7006
7423
  DEFAULT_PROTOCOL_VERSION = "2025-06-18";
7007
7424
  INSTRUCTIONS = "This is the live code map the human reviewing this repo is looking at right now. Prefer these tools over grep when you need to know who calls something, what would break if you change it, or where something lives \u2014 they're backed by a resolved call graph, not text search.";
7008
- isRecord2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7425
+ isRecord3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7009
7426
  isStrArr = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
7010
7427
  isDirection = (v) => v === "out" || v === "in" || v === "both";
7011
7428
  isStatus = (v) => v === "proposed" || v === "active" || v === "review" || v === "done";
7429
+ str2 = (v) => typeof v === "string" ? v.trim() : "";
7012
7430
  MAX_LAYER_FILES = 300;
7013
7431
  withCommand = (ctx) => ctx.command ? { command: ctx.command } : {};
7014
- MAX_STEPS = 12;
7432
+ MAX_STEPS = 40;
7015
7433
  TOOLS = {
7016
7434
  find: {
7017
7435
  description: "Search blocks (files, classes, functions, methods) by name or text. Use this instead of grep to locate where something lives before reading or changing it.",
@@ -7190,21 +7608,21 @@ var init_mcp = __esm({
7190
7608
  }
7191
7609
  },
7192
7610
  review_feedback: {
7193
- description: "The human's review of changed blocks: which need changes (with their note) and how many they approved. Use when you finish, or when told your change was reviewed. An agent Topo launched sees only its own territory and the blocks it changed.",
7611
+ description: "The human's review of changed blocks: which need changes (with their note) and how many they've looked at. Use when you finish, or when told your change was reviewed. An agent Topo launched sees only its own territory and the blocks it changed.",
7194
7612
  inputSchema: { type: "object", properties: {} },
7195
7613
  run: (deps, _args, ctx) => {
7196
7614
  const index = deps.getIndex();
7197
7615
  const run8 = ctx.runId ? deps.agents?.get(ctx.runId) : void 0;
7198
7616
  const territory = run8 && deps.getTerritories().find((t) => t.id === run8.territoryId);
7199
7617
  const mine = (id) => !territory || deps.tracking.blockAgent.get(id) === run8?.agent || outsideTerritory(index, territory.nodeIds, [id]).length === 0;
7200
- const reviewed = deps.getPayload().changeSet.blocks.filter((b) => b.review === "approved" || b.review === "changes_requested").filter((b) => mine(b.nodeId));
7618
+ const reviewed = deps.getPayload().changeSet.blocks.filter((b) => b.review === "viewed" || b.review === "changes_requested").filter((b) => mine(b.nodeId));
7201
7619
  if (!reviewed.length) return "no review feedback yet";
7202
7620
  const asks = reviewed.filter((b) => b.review === "changes_requested");
7203
7621
  return [
7204
7622
  ...asks.map(
7205
7623
  (b) => `${b.nodeId} CHANGES REQUESTED${b.reviewNote ? `: ${b.reviewNote}` : ""}`
7206
7624
  ),
7207
- `${reviewed.length - asks.length} block(s) approved`
7625
+ `${reviewed.length - asks.length} block(s) viewed, no changes asked`
7208
7626
  ].join("\n");
7209
7627
  }
7210
7628
  },
@@ -7223,16 +7641,23 @@ var init_mcp = __esm({
7223
7641
  const input = args.input;
7224
7642
  const run8 = deps.agents?.get(ctx.runId);
7225
7643
  const label = run8?.label ?? "An agent";
7226
- const detail = toolName === "Bash" && isRecord2(input) && typeof input.command === "string" ? input.command : JSON.stringify(input);
7644
+ const detail = toolName === "Bash" && isRecord3(input) && typeof input.command === "string" ? input.command : JSON.stringify(input);
7645
+ const allow = JSON.stringify({ behavior: "allow", updatedInput: input });
7646
+ if ((deps.autoAllowRules?.() ?? []).some((r) => ruleMatches(r, toolName, input)))
7647
+ return allow;
7648
+ const rule = ruleFor(toolName, input);
7227
7649
  const item = deps.inbox.add({
7228
7650
  kind: "permission",
7229
- title: `${label} wants to run ${toolName}: ${detail}`,
7651
+ // The command goes in detail, so the card can show it on one line as code.
7652
+ title: `${label} wants to run ${toolName}`,
7653
+ detail,
7230
7654
  agent: run8?.agent,
7231
7655
  label: run8?.label,
7232
- runId: ctx.runId
7656
+ runId: ctx.runId,
7657
+ ...rule && { rule, actions: ["approve", "always", "deny"] }
7233
7658
  });
7234
7659
  const status = await deps.inbox.wait(item.id, 10 * 60 * 1e3);
7235
- return status === "approved" ? JSON.stringify({ behavior: "allow", updatedInput: input }) : JSON.stringify({ behavior: "deny", message: "The human denied this in Topo." });
7660
+ return status === "approved" ? allow : JSON.stringify({ behavior: "deny", message: "The human denied this in Topo." });
7236
7661
  }
7237
7662
  },
7238
7663
  list_territories: {
@@ -7300,7 +7725,7 @@ var init_mcp = __esm({
7300
7725
  label,
7301
7726
  ...typeof args.mode === "string" && { mode: args.mode },
7302
7727
  ...roots.length && { roots },
7303
- view: isRecord2(args.view) ? args.view : {}
7728
+ view: isRecord3(args.view) ? args.view : {}
7304
7729
  };
7305
7730
  const problem = lensProblem(candidate);
7306
7731
  if (problem) throw new Error(`invalid lens: ${problem}`);
@@ -7333,7 +7758,7 @@ var init_mcp = __esm({
7333
7758
  },
7334
7759
  explain: {
7335
7760
  access: "director",
7336
- description: "Walk the human through how something works: the map shows only these blocks, numbered in step order, with the calls between them. Use for 'how does X work / where is X / what happens when' questions, after tracing with find/callees/callers/get_block. Give 3-8 steps in execution (or reading) order, only blocks that matter to the question; each step's text says what happens there in one or two sentences.",
7761
+ description: `Walk the human through how something works, or through a change: the map tours these blocks in step order, numbered, with a card per step. Use for 'how does X work / where is X / what happens when / walk me through this or the changes' requests, after tracing with find/callees/callers/get_block. Give the steps in execution (or reading) order, sized to the question (about 5 for the gist, every step that matters for all of it, at most ${MAX_STEPS}), only blocks that matter; each step's text says what happens there in 2-4 sentences (for a changed block, also what changed and why it matters).`,
7337
7762
  inputSchema: {
7338
7763
  type: "object",
7339
7764
  properties: {
@@ -7359,8 +7784,8 @@ var init_mcp = __esm({
7359
7784
  const steps = [];
7360
7785
  const problems = [];
7361
7786
  raw.forEach((s, i) => {
7362
- const ref = isRecord2(s) && typeof s.block === "string" ? s.block : "";
7363
- const text = isRecord2(s) && typeof s.text === "string" ? s.text.trim() : "";
7787
+ const ref = isRecord3(s) && typeof s.block === "string" ? s.block : "";
7788
+ const text = isRecord3(s) && typeof s.text === "string" ? s.text.trim() : "";
7364
7789
  const r = resolveOrText(index, ref);
7365
7790
  if ("node" in r) steps.push({ nodeId: r.node.id, text });
7366
7791
  else problems.push(`step ${i + 1} (${ref || "no block"}): ${r.text}`);
@@ -7374,6 +7799,45 @@ var init_mcp = __esm({
7374
7799
  return `showing "${title}": ${steps.length} step(s) on the map`;
7375
7800
  }
7376
7801
  },
7802
+ ask_user: {
7803
+ access: "director",
7804
+ description: "Ask the human 1-4 multiple-choice questions, shown as buttons under your reply (they can also type their own answer). Use it when the request is ambiguous in a way that changes what you'd do: how many agents, which territories to make, how long a walkthrough, which of several areas. Each option is a concrete choice you can act on. After calling it, end your turn with at most a line of reply; the answer arrives as the human's next message.",
7805
+ inputSchema: {
7806
+ type: "object",
7807
+ properties: {
7808
+ questions: {
7809
+ type: "array",
7810
+ minItems: 1,
7811
+ maxItems: 4,
7812
+ items: {
7813
+ type: "object",
7814
+ properties: {
7815
+ question: { type: "string" },
7816
+ header: { type: "string", description: "a label of 12 characters at most" },
7817
+ options: {
7818
+ type: "array",
7819
+ minItems: 2,
7820
+ maxItems: 4,
7821
+ items: {
7822
+ type: "object",
7823
+ properties: { label: { type: "string" }, description: { type: "string" } },
7824
+ required: ["label"]
7825
+ }
7826
+ },
7827
+ multiSelect: { type: "boolean" }
7828
+ },
7829
+ required: ["question", "options"]
7830
+ }
7831
+ }
7832
+ },
7833
+ required: ["questions"]
7834
+ },
7835
+ run: (deps, args, ctx) => {
7836
+ const questions = parseQuestions(args.questions);
7837
+ deps.bus.emit({ type: "ask", questions, ...withCommand(ctx) });
7838
+ return `asked ${questions.length} question(s); they show under your reply. End your turn now: the human's choice comes back as their next message.`;
7839
+ }
7840
+ },
7377
7841
  get_layers: {
7378
7842
  description: "The repo's layers (.topo/layers.json: named groups of files by path glob, which color the map) and which files are in none. With no layers yet, a folder-based draft. Lists every source file with its main blocks, so you can group them by what the code does.",
7379
7843
  inputSchema: { type: "object", properties: {} },
@@ -7545,16 +8009,17 @@ ${loose.length} file(s) in no layer: ${loose.slice(0, 20).join(", ")}${loose.len
7545
8009
  },
7546
8010
  launch_agent: {
7547
8011
  access: "director",
7548
- description: "Launch a worker agent on a territory. Use when the human asks to start work.",
8012
+ description: "Launch a worker agent on a territory. Use when the human asks to start work or asks for a code change (you can't edit code; the worker can). `agent` picks the CLI (claude, codex, gemini) when the human names one; otherwise the territory's own or the default.",
7549
8013
  inputSchema: {
7550
8014
  type: "object",
7551
- properties: { territory: { type: "string" } },
8015
+ properties: { territory: { type: "string" }, agent: { type: "string" } },
7552
8016
  required: ["territory"]
7553
8017
  },
7554
8018
  run: (deps, args) => {
7555
8019
  if (!deps.agents?.available) throw new Error("agent launching isn't available right now");
7556
8020
  const territory = requireString(args, "territory");
7557
- const run8 = deps.agents.launch(territory);
8021
+ const agent = typeof args.agent === "string" && args.agent ? args.agent : void 0;
8022
+ const run8 = deps.agents.launch(territory, void 0, agent);
7558
8023
  return `launched ${run8.label} (${run8.id}) on territory "${territory}"`;
7559
8024
  }
7560
8025
  },
@@ -7629,11 +8094,11 @@ ${loose.length} file(s) in no layer: ${loose.slice(0, 20).join(", ")}${loose.len
7629
8094
  });
7630
8095
 
7631
8096
  // packages/cli/src/territories.ts
7632
- import fs16 from "node:fs";
7633
- import path19 from "node:path";
8097
+ import fs18 from "node:fs";
8098
+ import path21 from "node:path";
7634
8099
  function readTerritories(repo) {
7635
8100
  try {
7636
- const doc = parseTerritoryDoc(JSON.parse(fs16.readFileSync(territoriesFile(repo), "utf8")));
8101
+ const doc = parseTerritoryDoc(JSON.parse(fs18.readFileSync(territoriesFile(repo), "utf8")));
7637
8102
  if (doc) return doc;
7638
8103
  } catch {
7639
8104
  }
@@ -7641,11 +8106,11 @@ function readTerritories(repo) {
7641
8106
  }
7642
8107
  function writeTerritories(repo, doc) {
7643
8108
  const file = territoriesFile(repo);
7644
- fs16.mkdirSync(path19.dirname(file), { recursive: true });
8109
+ fs18.mkdirSync(path21.dirname(file), { recursive: true });
7645
8110
  const tmp = `${file}.${process.pid}.tmp`;
7646
- fs16.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
8111
+ fs18.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
7647
8112
  `);
7648
- fs16.renameSync(tmp, file);
8113
+ fs18.renameSync(tmp, file);
7649
8114
  }
7650
8115
  function parseTerritoryDoc(v) {
7651
8116
  const o = v;
@@ -7661,6 +8126,7 @@ function parseTerritoryDoc(v) {
7661
8126
  if (typeof r.label !== "string" || !isStrArr2(r.nodeIds)) return void 0;
7662
8127
  if (r.agent !== void 0 && typeof r.agent !== "string") return void 0;
7663
8128
  if (r.note !== void 0 && typeof r.note !== "string") return void 0;
8129
+ if (r.runner !== void 0 && typeof r.runner !== "string") return void 0;
7664
8130
  if (r.status !== void 0 && !STATUSES.includes(r.status)) return void 0;
7665
8131
  seen.add(r.id);
7666
8132
  territories.push({
@@ -7669,6 +8135,7 @@ function parseTerritoryDoc(v) {
7669
8135
  nodeIds: r.nodeIds,
7670
8136
  ...r.agent !== void 0 ? { agent: r.agent } : {},
7671
8137
  ...r.note !== void 0 ? { note: r.note } : {},
8138
+ ...r.runner !== void 0 ? { runner: r.runner } : {},
7672
8139
  ...r.status !== void 0 ? { status: r.status } : {}
7673
8140
  });
7674
8141
  }
@@ -7679,7 +8146,7 @@ var init_territories = __esm({
7679
8146
  "packages/cli/src/territories.ts"() {
7680
8147
  "use strict";
7681
8148
  SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7682
- territoriesFile = (repo) => path19.join(repo, ".topo", "territories.json");
8149
+ territoriesFile = (repo) => path21.join(repo, ".topo", "territories.json");
7683
8150
  isStrArr2 = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
7684
8151
  STATUSES = ["proposed", "active", "review", "done"];
7685
8152
  }
@@ -7687,9 +8154,9 @@ var init_territories = __esm({
7687
8154
 
7688
8155
  // packages/cli/src/server.ts
7689
8156
  import crypto from "node:crypto";
7690
- import fs17 from "node:fs";
8157
+ import fs19 from "node:fs";
7691
8158
  import http from "node:http";
7692
- import path20 from "node:path";
8159
+ import path22 from "node:path";
7693
8160
  import { fileURLToPath as fileURLToPath3 } from "node:url";
7694
8161
  function send(res, status, body, type) {
7695
8162
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
@@ -7728,27 +8195,27 @@ function parseView(v) {
7728
8195
  }
7729
8196
  function parseLayout(v) {
7730
8197
  const o = v;
7731
- if (!isRecord3(o) || o.version !== 1 || !isRecord3(o.views) || !Object.values(o.views).every((pins) => isRecord3(pins) && Object.values(pins).every(isPos))) {
8198
+ if (!isRecord4(o) || o.version !== 1 || !isRecord4(o.views) || !Object.values(o.views).every((pins) => isRecord4(pins) && Object.values(pins).every(isPos))) {
7732
8199
  throw new HttpError(400, "invalid layout");
7733
8200
  }
7734
8201
  return { version: 1, views: o.views };
7735
8202
  }
7736
8203
  function serveStatic(res, staticDir, urlPath2) {
7737
- const root = path20.resolve(staticDir);
7738
- const index = path20.join(root, "index.html");
8204
+ const root = path22.resolve(staticDir);
8205
+ const index = path22.join(root, "index.html");
7739
8206
  let decoded;
7740
8207
  try {
7741
8208
  decoded = decodeURIComponent(urlPath2);
7742
8209
  } catch {
7743
8210
  throw new HttpError(400, "bad path");
7744
8211
  }
7745
- let file = path20.resolve(root, `.${decoded}`);
7746
- if (file !== root && !file.startsWith(root + path20.sep)) throw new HttpError(403, "forbidden");
7747
- if (!fs17.statSync(file, { throwIfNoEntry: false })?.isFile()) file = index;
7748
- if (!fs17.existsSync(file)) return send(res, 200, MISSING_DIST, TYPES[".html"]);
7749
- const type = TYPES[path20.extname(file).toLowerCase()] ?? "application/octet-stream";
8212
+ let file = path22.resolve(root, `.${decoded}`);
8213
+ if (file !== root && !file.startsWith(root + path22.sep)) throw new HttpError(403, "forbidden");
8214
+ if (!fs19.statSync(file, { throwIfNoEntry: false })?.isFile()) file = index;
8215
+ if (!fs19.existsSync(file)) return send(res, 200, MISSING_DIST, TYPES[".html"]);
8216
+ const type = TYPES[path22.extname(file).toLowerCase()] ?? "application/octet-stream";
7750
8217
  res.writeHead(200, { "content-type": type });
7751
- fs17.createReadStream(file).pipe(res);
8218
+ fs19.createReadStream(file).pipe(res);
7752
8219
  }
7753
8220
  function isLocalhostOrigin(origin) {
7754
8221
  try {
@@ -7764,7 +8231,7 @@ function constantTimeEqual(a, b) {
7764
8231
  }
7765
8232
  function parseCommandContext(v) {
7766
8233
  if (v === void 0) return void 0;
7767
- if (!isRecordLike(v) || v.mode !== void 0 && typeof v.mode !== "string" || v.roots !== void 0 && !isStrArr3(v.roots) || v.selected !== void 0 && !isStrArr3(v.selected)) {
8234
+ if (!isRecordLike(v) || v.mode !== void 0 && typeof v.mode !== "string" || v.roots !== void 0 && !isStrArr3(v.roots) || v.depth !== void 0 && !Number.isInteger(v.depth) || v.selected !== void 0 && !isStrArr3(v.selected)) {
7768
8235
  throw new HttpError(400, "invalid command context");
7769
8236
  }
7770
8237
  return v;
@@ -7844,15 +8311,32 @@ function createServer(opts) {
7844
8311
  opts.liveEvents?.emit({ type: "territories", territories: doc.territories });
7845
8312
  return json(res, 200, doc);
7846
8313
  }
8314
+ if (route === "GET /api/settings" && opts.getSettings)
8315
+ return json(res, 200, opts.getSettings());
8316
+ if (route === "PUT /api/settings" && opts.saveSettings) {
8317
+ const body = await readJson(req);
8318
+ if (!PERMISSION_MODES.includes(body?.permissions)) {
8319
+ throw new HttpError(400, `expected {permissions: ${PERMISSION_MODES.join("|")}}`);
8320
+ }
8321
+ return json(res, 200, opts.saveSettings(body));
8322
+ }
7847
8323
  if (route === "GET /api/inbox" && opts.inbox) return json(res, 200, opts.inbox.list());
7848
8324
  const inboxDecide = req.method === "POST" && /^\/api\/inbox\/[^/]+$/.test(url.pathname);
7849
8325
  if (inboxDecide && opts.inbox) {
7850
8326
  const id = decodeURIComponent(url.pathname.slice("/api/inbox/".length));
7851
8327
  const body = await readJson(req);
7852
- if (body?.decision !== "approve" && body?.decision !== "deny" && body?.decision !== "dismiss") {
7853
- throw new HttpError(400, "expected {decision: approve|deny|dismiss}");
8328
+ const decision = body?.decision;
8329
+ if (decision !== "approve" && decision !== "always" && decision !== "deny" && decision !== "dismiss") {
8330
+ throw new HttpError(400, "expected {decision: approve|always|deny|dismiss}");
8331
+ }
8332
+ if (decision === "always") {
8333
+ const open = opts.inbox.list().find((it) => it.id === id);
8334
+ if (!open?.rule || !open.actions.includes("always") || !opts.allowAlways) {
8335
+ throw new HttpError(400, "this item has no rule to always allow");
8336
+ }
8337
+ opts.allowAlways(open);
7854
8338
  }
7855
- const item = opts.inbox.decide(id, body.decision);
8339
+ const item = opts.inbox.decide(id, decision === "always" ? "approve" : decision);
7856
8340
  if (!item) throw new HttpError(404, "no such inbox item");
7857
8341
  return json(res, 200, item);
7858
8342
  }
@@ -7862,10 +8346,11 @@ function createServer(opts) {
7862
8346
  try {
7863
8347
  const body = await readJson(req).catch(() => null);
7864
8348
  const reply = typeof body?.reply === "string" && body.reply.trim() ? body.reply : void 0;
8349
+ const agent = typeof body?.agent === "string" && body.agent ? body.agent : void 0;
7865
8350
  return json(
7866
8351
  res,
7867
8352
  200,
7868
- opts.agents.launch(decodeURIComponent(launchMatch[1]), reply)
8353
+ opts.agents.launch(decodeURIComponent(launchMatch[1]), reply, agent)
7869
8354
  );
7870
8355
  } catch (e) {
7871
8356
  throw new HttpError(400, e.message);
@@ -7922,11 +8407,11 @@ function createServer(opts) {
7922
8407
  }
7923
8408
  if (route === "POST /api/blocks/review" && opts.reviewBlock) {
7924
8409
  const b = await readJson(req);
7925
- const decision = b?.decision;
7926
- if (typeof b?.nodeId !== "string" || decision !== "approved" && decision !== "changes_requested" && decision !== "pending" || b.note !== void 0 && (typeof b.note !== "string" || b.note.length > 4e3)) {
8410
+ const decision = b?.decision === "approved" ? "viewed" : b?.decision;
8411
+ if (typeof b?.nodeId !== "string" || decision !== "viewed" && decision !== "changes_requested" && decision !== "pending" || b.note !== void 0 && (typeof b.note !== "string" || b.note.length > 4e3)) {
7927
8412
  throw new HttpError(
7928
8413
  400,
7929
- "expected {nodeId, decision: approved|changes_requested|pending, note?}"
8414
+ "expected {nodeId, decision: viewed|changes_requested|pending, note?}"
7930
8415
  );
7931
8416
  }
7932
8417
  if (!opts.reviewBlock(b.nodeId, decision, b.note)) {
@@ -7934,6 +8419,15 @@ function createServer(opts) {
7934
8419
  }
7935
8420
  return json(res, 200, { ok: true });
7936
8421
  }
8422
+ if (route === "POST /api/blocks/ask" && opts.askChanges) {
8423
+ const b = await readJson(req);
8424
+ if (typeof b?.nodeId !== "string" || typeof b.note !== "string" || !b.note.trim() || b.note.length > 4e3) {
8425
+ throw new HttpError(400, "expected {nodeId, note} with a non-empty note");
8426
+ }
8427
+ const result = opts.askChanges(b.nodeId, b.note.trim());
8428
+ if (!result) throw new HttpError(404, "no such changed block");
8429
+ return json(res, 200, result);
8430
+ }
7937
8431
  if (route === "POST /api/activity" && opts.postActivity) {
7938
8432
  const body = await readJson(req);
7939
8433
  const accepted = opts.postActivity(body);
@@ -7957,17 +8451,18 @@ function createServer(opts) {
7957
8451
  }
7958
8452
  });
7959
8453
  }
7960
- var KEEPALIVE_MS, REPO_WEB_DIST, DEFAULT_STATIC_DIR, MAX_BODY, TYPES, MISSING_DIST, HttpError, isStrArr3, isPos, isRecord3, seenShapes, isRecordLike;
8454
+ var KEEPALIVE_MS, REPO_WEB_DIST, DEFAULT_STATIC_DIR, MAX_BODY, TYPES, MISSING_DIST, HttpError, isStrArr3, isPos, isRecord4, seenShapes, isRecordLike;
7961
8455
  var init_server = __esm({
7962
8456
  async "packages/cli/src/server.ts"() {
7963
8457
  "use strict";
7964
8458
  await init_src();
7965
8459
  init_editor();
8460
+ init_permissions();
7966
8461
  init_territories();
7967
8462
  KEEPALIVE_MS = 15e3;
7968
8463
  REPO_WEB_DIST = fileURLToPath3(new URL("../../../apps/web/dist/", import.meta.url));
7969
8464
  DEFAULT_STATIC_DIR = [fileURLToPath3(new URL("./web/", import.meta.url)), REPO_WEB_DIST].find(
7970
- (d) => fs17.existsSync(path20.join(d, "index.html"))
8465
+ (d) => fs19.existsSync(path22.join(d, "index.html"))
7971
8466
  ) ?? REPO_WEB_DIST;
7972
8467
  MAX_BODY = 1024 * 1024;
7973
8468
  TYPES = {
@@ -7998,15 +8493,15 @@ var init_server = __esm({
7998
8493
  };
7999
8494
  isStrArr3 = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
8000
8495
  isPos = (p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === "number" && Number.isFinite(n));
8001
- isRecord3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
8496
+ isRecord4 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
8002
8497
  seenShapes = /* @__PURE__ */ new Set();
8003
8498
  isRecordLike = (v) => !!v && typeof v === "object" && !Array.isArray(v);
8004
8499
  }
8005
8500
  });
8006
8501
 
8007
8502
  // packages/cli/src/watch.ts
8008
- import fs18 from "node:fs";
8009
- import path21 from "node:path";
8503
+ import fs20 from "node:fs";
8504
+ import path23 from "node:path";
8010
8505
  function isIndexable(relPath) {
8011
8506
  const parts = relPath.split(/[\\/]/).filter(Boolean);
8012
8507
  const base = parts.at(-1);
@@ -8048,14 +8543,14 @@ function watchRepo(root, onBatch, opts = {}) {
8048
8543
  if (watchers.has(dir)) return;
8049
8544
  let w;
8050
8545
  try {
8051
- w = fs18.watch(dir, (_event, filename) => {
8546
+ w = fs20.watch(dir, (_event, filename) => {
8052
8547
  if (!filename) return;
8053
- const abs = path21.join(dir, filename.toString());
8054
- if (!watchers.has(abs) && fs18.statSync(abs, { throwIfNoEntry: false })?.isDirectory()) {
8055
- if (!isIgnoredDir(path21.basename(abs))) walkDirs(abs);
8548
+ const abs = path23.join(dir, filename.toString());
8549
+ if (!watchers.has(abs) && fs20.statSync(abs, { throwIfNoEntry: false })?.isDirectory()) {
8550
+ if (!isIgnoredDir(path23.basename(abs))) walkDirs(abs);
8056
8551
  return;
8057
8552
  }
8058
- const rel = path21.relative(root, abs).split(path21.sep).join("/");
8553
+ const rel = path23.relative(root, abs).split(path23.sep).join("/");
8059
8554
  if (!isIndexable(rel)) return;
8060
8555
  pending.add(rel);
8061
8556
  scheduleFlush();
@@ -8073,11 +8568,11 @@ function watchRepo(root, onBatch, opts = {}) {
8073
8568
  watchDir(dir);
8074
8569
  let entries = [];
8075
8570
  try {
8076
- entries = fs18.readdirSync(dir, { withFileTypes: true });
8571
+ entries = fs20.readdirSync(dir, { withFileTypes: true });
8077
8572
  } catch {
8078
8573
  }
8079
8574
  for (const e of entries)
8080
- if (e.isDirectory() && !isIgnoredDir(e.name)) walkDirs(path21.join(dir, e.name));
8575
+ if (e.isDirectory() && !isIgnoredDir(e.name)) walkDirs(path23.join(dir, e.name));
8081
8576
  };
8082
8577
  walkDirs(root);
8083
8578
  return {
@@ -8102,8 +8597,8 @@ var init_watch = __esm({
8102
8597
 
8103
8598
  // packages/cli/src/worktrees.ts
8104
8599
  import { execFileSync } from "node:child_process";
8105
- import fs19 from "node:fs";
8106
- import path22 from "node:path";
8600
+ import fs21 from "node:fs";
8601
+ import path24 from "node:path";
8107
8602
  function listWorktrees(repo) {
8108
8603
  let out;
8109
8604
  try {
@@ -8117,17 +8612,17 @@ function listWorktrees(repo) {
8117
8612
  }
8118
8613
  const roots = out.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
8119
8614
  const own = roots.find((r) => isInside2(r, repo));
8120
- return roots.filter((r) => r !== own).map((root) => ({ root, name: path22.basename(root) }));
8615
+ return roots.filter((r) => r !== own).map((root) => ({ root, name: path24.basename(root) }));
8121
8616
  }
8122
8617
  function mapWorktreePath(repo, worktrees, file) {
8123
8618
  const wt = worktrees.filter((w) => isInside2(w.root, file)).sort((a, b) => b.root.length - a.root.length)[0];
8124
8619
  if (!wt) return void 0;
8125
8620
  const mainRoot = gitRoot(repo);
8126
- const sub = mainRoot ? path22.relative(mainRoot, repo) : "";
8127
- const inTree = path22.relative(wt.root, file);
8128
- const rel = sub ? path22.relative(sub, inTree) : inTree;
8129
- if (rel.startsWith("..") || path22.isAbsolute(rel)) return void 0;
8130
- return { rel: rel.split(path22.sep).join("/"), worktree: wt };
8621
+ const sub = mainRoot ? path24.relative(mainRoot, repo) : "";
8622
+ const inTree = path24.relative(wt.root, file);
8623
+ const rel = sub ? path24.relative(sub, inTree) : inTree;
8624
+ if (rel.startsWith("..") || path24.isAbsolute(rel)) return void 0;
8625
+ return { rel: rel.split(path24.sep).join("/"), worktree: wt };
8131
8626
  }
8132
8627
  function gitRoot(dir) {
8133
8628
  try {
@@ -8141,7 +8636,7 @@ function gitRoot(dir) {
8141
8636
  }
8142
8637
  }
8143
8638
  function changedLines(base, changed) {
8144
- if (!fs19.existsSync(base) || !fs19.existsSync(changed)) return [];
8639
+ if (!fs21.existsSync(base) || !fs21.existsSync(changed)) return [];
8145
8640
  let text = "";
8146
8641
  try {
8147
8642
  execFileSync("git", ["diff", "--no-index", "-U0", "--no-color", base, changed], {
@@ -8174,8 +8669,8 @@ var init_worktrees = __esm({
8174
8669
  "packages/cli/src/worktrees.ts"() {
8175
8670
  "use strict";
8176
8671
  isInside2 = (dir, p) => {
8177
- const rel = path22.relative(dir, p);
8178
- return rel === "" || !rel.startsWith("..") && !path22.isAbsolute(rel);
8672
+ const rel = path24.relative(dir, p);
8673
+ return rel === "" || !rel.startsWith("..") && !path24.isAbsolute(rel);
8179
8674
  };
8180
8675
  }
8181
8676
  });
@@ -8191,8 +8686,8 @@ __export(review_exports, {
8191
8686
  });
8192
8687
  import { spawn as spawn2 } from "node:child_process";
8193
8688
  import crypto2 from "node:crypto";
8194
- import fs20 from "node:fs";
8195
- import path23 from "node:path";
8689
+ import fs22 from "node:fs";
8690
+ import path25 from "node:path";
8196
8691
  import { parseArgs as parseArgs4 } from "node:util";
8197
8692
  function openBrowser(url) {
8198
8693
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
@@ -8231,7 +8726,7 @@ function run4(argv) {
8231
8726
  if (!base) throw new Error(`missing <base> ref
8232
8727
 
8233
8728
  ${HELP4}`);
8234
- const repo = path23.resolve(values.repo ?? process.cwd());
8729
+ const repo = path25.resolve(values.repo ?? process.cwd());
8235
8730
  return serve({
8236
8731
  compute: () => computeReview(repo, base, values.head),
8237
8732
  repo,
@@ -8246,7 +8741,7 @@ ${HELP4}`);
8246
8741
  }
8247
8742
  function readLayout(repo) {
8248
8743
  try {
8249
- const doc = JSON.parse(fs20.readFileSync(layoutFile(repo), "utf8"));
8744
+ const doc = JSON.parse(fs22.readFileSync(layoutFile(repo), "utf8"));
8250
8745
  if (doc?.version === 1 && doc.views && typeof doc.views === "object") return doc;
8251
8746
  } catch {
8252
8747
  }
@@ -8254,12 +8749,12 @@ function readLayout(repo) {
8254
8749
  }
8255
8750
  function writeLayout(repo, doc) {
8256
8751
  const file = layoutFile(repo);
8257
- fs20.mkdirSync(path23.dirname(file), { recursive: true });
8752
+ fs22.mkdirSync(path25.dirname(file), { recursive: true });
8258
8753
  const tmp = `${file}.${process.pid}.tmp`;
8259
8754
  const text = JSON.stringify(doc, null, 2).replace(/\[\s+(-?\d+),\s+(-?\d+)\s+\]/g, "[$1, $2]");
8260
- fs20.writeFileSync(tmp, `${text}
8755
+ fs22.writeFileSync(tmp, `${text}
8261
8756
  `);
8262
- fs20.renameSync(tmp, file);
8757
+ fs22.renameSync(tmp, file);
8263
8758
  }
8264
8759
  function serve(opts) {
8265
8760
  const port = Number(opts.port);
@@ -8268,6 +8763,7 @@ function serve(opts) {
8268
8763
  const { repo } = opts;
8269
8764
  const editorCommand = editorTemplate(opts.editor);
8270
8765
  const tracking = { blockAgent: /* @__PURE__ */ new Map(), rationale: /* @__PURE__ */ new Map(), reviews: /* @__PURE__ */ new Map() };
8766
+ let marksFor;
8271
8767
  const compute = () => {
8272
8768
  const review = opts.compute();
8273
8769
  tagHubs(review.head);
@@ -8278,6 +8774,11 @@ function serve(opts) {
8278
8774
  lenses: loadLenses(repo),
8279
8775
  territories
8280
8776
  };
8777
+ const key = reviewKey(payload.changeSet);
8778
+ if (key !== marksFor) {
8779
+ marksFor = key;
8780
+ tracking.reviews = readReviews(repo, key);
8781
+ }
8281
8782
  payload.changeSet = annotateChangeSet(payload.changeSet, { territories, tracking, index });
8282
8783
  return { review, payload, index };
8283
8784
  };
@@ -8308,7 +8809,7 @@ function serve(opts) {
8308
8809
  const nodeIds = blocksAt(
8309
8810
  state.index,
8310
8811
  parsed.file,
8311
- changedLines(path23.join(repo, parsed.file), parsed.absFile)
8812
+ changedLines(path25.join(repo, parsed.file), parsed.absFile)
8312
8813
  );
8313
8814
  const run9 = agents.list().findLast((r) => r.agent === parsed.agent || r.agent === parsed.session);
8314
8815
  const { absFile: _abs, worktree, ...activity } = parsed;
@@ -8340,6 +8841,7 @@ function serve(opts) {
8340
8841
  return true;
8341
8842
  };
8342
8843
  const directorToken = crypto2.randomBytes(24).toString("hex");
8844
+ const permissionMode = () => opts.skipPermissions ? "never" : readSettings(repo).permissions;
8343
8845
  let boundPort = port;
8344
8846
  const agents = createAgentManager({
8345
8847
  repo,
@@ -8350,7 +8852,9 @@ function serve(opts) {
8350
8852
  saveTerritories: saveTerritoriesAndEmit,
8351
8853
  directorToken,
8352
8854
  getIndex: () => state.index,
8353
- skipPermissions: opts.skipPermissions
8855
+ permissionMode,
8856
+ // Agents without Claude's edit hook report their edits in their own stream.
8857
+ onEdit: (agent, file) => postActivity({ agent, file })
8354
8858
  });
8355
8859
  let live;
8356
8860
  let watcher;
@@ -8389,16 +8893,45 @@ function serve(opts) {
8389
8893
  saveTerritories: saveTerritoriesAndEmit,
8390
8894
  inbox,
8391
8895
  agents,
8392
- tracking
8896
+ tracking,
8897
+ autoAllowRules: () => {
8898
+ const mode = permissionMode();
8899
+ return mode === "never" ? [] : [...mode === "safe" ? SAFE_RULES : [], ...readLocalAllow(repo)];
8900
+ }
8393
8901
  });
8902
+ const markBlock = (nodeId2, decision, note) => {
8903
+ const block2 = state.payload.changeSet.blocks.find((b) => b.nodeId === nodeId2);
8904
+ if (!block2) return false;
8905
+ if (decision === "pending") tracking.reviews.delete(nodeId2);
8906
+ else
8907
+ tracking.reviews.set(nodeId2, {
8908
+ decision,
8909
+ ...note && { note },
8910
+ ...decision === "viewed" && { fp: fingerprint(block2, state.index.byId.get(nodeId2)) }
8911
+ });
8912
+ block2.review = decision;
8913
+ delete block2.viewedStale;
8914
+ if (note && decision === "changes_requested") block2.reviewNote = note;
8915
+ else delete block2.reviewNote;
8916
+ try {
8917
+ writeReviews(repo, reviewKey(state.payload.changeSet), tracking.reviews);
8918
+ } catch (e) {
8919
+ console.error(
8920
+ `topo: couldn't save .topo/reviews.json: ${e instanceof Error ? e.message : e}`
8921
+ );
8922
+ }
8923
+ return true;
8924
+ };
8394
8925
  const server = createServer({
8395
8926
  getPayload: (refresh) => {
8396
8927
  if (refresh) state = compute();
8397
8928
  state.payload.inbox = inbox.list();
8398
8929
  state.payload.runs = agents.list();
8399
- state.payload.commandBar = agents.available ? "agent" : "search";
8930
+ state.payload.commandBar = agents.director ? "agent" : "search";
8931
+ state.payload.agentClis = agents.agents();
8400
8932
  state.payload.exchanges = agents.exchanges();
8401
8933
  if (opts.skipPermissions) state.payload.skipPermissions = true;
8934
+ state.payload.agentPermissions = permissionMode();
8402
8935
  const layers = readLayers(repo);
8403
8936
  state.payload.layers = layers && layerMap(layers, state.review.head);
8404
8937
  return state.payload;
@@ -8413,22 +8946,39 @@ function serve(opts) {
8413
8946
  directorToken,
8414
8947
  inbox,
8415
8948
  agents,
8949
+ allowAlways: (item) => item.rule && addLocalAllowRule(repo, item.rule),
8950
+ getSettings: () => readSettings(repo),
8951
+ saveSettings: (raw) => {
8952
+ const s = parseSettings(raw);
8953
+ writeSettings(repo, s);
8954
+ return s;
8955
+ },
8416
8956
  liveEvents: bus,
8417
8957
  postActivity,
8418
- reviewBlock: (nodeId2, decision, note) => {
8958
+ reviewBlock: markBlock,
8959
+ askChanges: (nodeId2, note) => {
8960
+ if (!markBlock(nodeId2, "changes_requested", note)) return void 0;
8419
8961
  const block2 = state.payload.changeSet.blocks.find((b) => b.nodeId === nodeId2);
8420
- if (!block2) return false;
8421
- if (decision === "pending") {
8422
- tracking.reviews.delete(nodeId2);
8423
- block2.review = "pending";
8424
- delete block2.reviewNote;
8425
- } else {
8426
- tracking.reviews.set(nodeId2, { decision, ...note && { note } });
8427
- block2.review = decision;
8428
- if (note) block2.reviewNote = note;
8429
- else delete block2.reviewNote;
8962
+ try {
8963
+ const own = block2?.agentTerritory;
8964
+ if (own) {
8965
+ agents.launch(own, askReply([{ nodeId: nodeId2, note }]));
8966
+ return { to: "agent", territoryId: own };
8967
+ }
8968
+ const territories = getTerritories();
8969
+ const label = `Fix ${state.index.byId.get(nodeId2)?.name ?? nodeId2}`;
8970
+ const id = uniqueSlug(slugify(label), territories);
8971
+ saveTerritoriesAndEmit([
8972
+ ...territories,
8973
+ { id, label, nodeIds: [nodeId2], note: `${note}
8974
+
8975
+ The block: ${nodeId2}` }
8976
+ ]);
8977
+ agents.launch(id);
8978
+ return { to: "worker", territoryId: id };
8979
+ } catch (e) {
8980
+ return { to: "saved", error: e instanceof Error ? e.message : String(e) };
8430
8981
  }
8431
- return true;
8432
8982
  },
8433
8983
  staticDir: DEFAULT_STATIC_DIR,
8434
8984
  editorCommand,
@@ -8467,6 +9017,8 @@ var init_review2 = __esm({
8467
9017
  init_inbox();
8468
9018
  await init_live2();
8469
9019
  await init_mcp();
9020
+ init_permissions();
9021
+ init_reviews();
8470
9022
  await init_server();
8471
9023
  init_territories();
8472
9024
  init_watch();
@@ -8488,7 +9040,7 @@ Options:
8488
9040
  --dangerously-skip-permissions launched agents run with no permission prompts (like Claude Code's
8489
9041
  flag of the same name): nothing waits in the Inbox. For this server session only.
8490
9042
  -h, --help`;
8491
- layoutFile = (repo) => path23.join(repo, ".topo", "layout.json");
9043
+ layoutFile = (repo) => path25.join(repo, ".topo", "layout.json");
8492
9044
  }
8493
9045
  });
8494
9046
 
@@ -8499,8 +9051,8 @@ __export(export_exports, {
8499
9051
  run: () => run5,
8500
9052
  snapshotPage: () => snapshotPage
8501
9053
  });
8502
- import fs21 from "node:fs";
8503
- import path24 from "node:path";
9054
+ import fs23 from "node:fs";
9055
+ import path26 from "node:path";
8504
9056
  import { parseArgs as parseArgs5 } from "node:util";
8505
9057
  function snapshotPage(template, data) {
8506
9058
  const json2 = JSON.stringify(data).replaceAll("<", "\\u003c");
@@ -8530,7 +9082,7 @@ function run5(argv) {
8530
9082
  if (!base) throw new Error(`missing <base> ref
8531
9083
 
8532
9084
  ${HELP5}`);
8533
- const repo = path24.resolve(values.repo ?? process.cwd());
9085
+ const repo = path26.resolve(values.repo ?? process.cwd());
8534
9086
  const review = computeReview(repo, base, values.head);
8535
9087
  const payload = { ...toPayload(repo, review, ""), live: void 0 };
8536
9088
  const sources = {};
@@ -8540,14 +9092,14 @@ ${HELP5}`);
8540
9092
  if (s) sources[`${side} ${b.nodeId}`] = s;
8541
9093
  }
8542
9094
  }
8543
- const templateFile = path24.join(DEFAULT_STATIC_DIR, "snapshot.html");
8544
- if (!fs21.existsSync(templateFile)) {
9095
+ const templateFile = path26.join(DEFAULT_STATIC_DIR, "snapshot.html");
9096
+ if (!fs23.existsSync(templateFile)) {
8545
9097
  throw new Error("the web UI isn't built: run `pnpm --filter @topo/web build` first");
8546
9098
  }
8547
- const template = fs21.readFileSync(templateFile, "utf8");
8548
- const out = path24.resolve(values.out);
8549
- fs21.writeFileSync(out, snapshotPage(template, { payload, sources }));
8550
- const kb = Math.round(fs21.statSync(out).size / 1024);
9099
+ const template = fs23.readFileSync(templateFile, "utf8");
9100
+ const out = path26.resolve(values.out);
9101
+ fs23.writeFileSync(out, snapshotPage(template, { payload, sources }));
9102
+ const kb = Math.round(fs23.statSync(out).size / 1024);
8551
9103
  console.log(`topo: ${review.changeSet.blocks.length} changed block(s) \u2192 ${out} (${kb} kB)`);
8552
9104
  }
8553
9105
  var HELP5;
@@ -8576,8 +9128,8 @@ __export(layers_exports, {
8576
9128
  HELP: () => HELP6,
8577
9129
  run: () => run6
8578
9130
  });
8579
- import fs22 from "node:fs";
8580
- import path25 from "node:path";
9131
+ import fs24 from "node:fs";
9132
+ import path27 from "node:path";
8581
9133
  import { parseArgs as parseArgs6 } from "node:util";
8582
9134
  function run6(argv) {
8583
9135
  const { values, positionals } = parseArgs6({
@@ -8589,18 +9141,18 @@ function run6(argv) {
8589
9141
  console.log(HELP6);
8590
9142
  return;
8591
9143
  }
8592
- const repo = path25.resolve(positionals[0] ?? process.cwd());
9144
+ const repo = path27.resolve(positionals[0] ?? process.cwd());
8593
9145
  const graph = indexRepo(repo);
8594
9146
  let doc = readLayers(repo);
8595
9147
  if (values.suggest) {
8596
9148
  if (doc) {
8597
9149
  console.log(
8598
- `topo: ${path25.relative(process.cwd(), layersFile(repo)) || layersFile(repo)} already exists; edit it instead.`
9150
+ `topo: ${path27.relative(process.cwd(), layersFile(repo)) || layersFile(repo)} already exists; edit it instead.`
8599
9151
  );
8600
9152
  } else {
8601
9153
  doc = suggestLayers(graph);
8602
- fs22.mkdirSync(path25.dirname(layersFile(repo)), { recursive: true });
8603
- fs22.writeFileSync(layersFile(repo), `${JSON.stringify(doc, null, 2)}
9154
+ fs24.mkdirSync(path27.dirname(layersFile(repo)), { recursive: true });
9155
+ fs24.writeFileSync(layersFile(repo), `${JSON.stringify(doc, null, 2)}
8604
9156
  `);
8605
9157
  console.log(
8606
9158
  `topo: wrote ${doc.layers.length} suggested layer(s) to ${layersFile(repo)}; name them, merge them, and add rules.`
@@ -8650,7 +9202,7 @@ __export(map_exports, {
8650
9202
  HELP: () => HELP7,
8651
9203
  run: () => run7
8652
9204
  });
8653
- import path26 from "node:path";
9205
+ import path28 from "node:path";
8654
9206
  import { parseArgs as parseArgs7 } from "node:util";
8655
9207
  function run7(argv) {
8656
9208
  const { values, positionals } = parseArgs7({
@@ -8673,7 +9225,7 @@ function run7(argv) {
8673
9225
  return;
8674
9226
  }
8675
9227
  if (!values.cache) process.env.TOPO_NO_CACHE = "1";
8676
- const dir = path26.resolve(positionals[0] ?? process.cwd());
9228
+ const dir = path28.resolve(positionals[0] ?? process.cwd());
8677
9229
  return serve({
8678
9230
  compute: () => computeExplore(dir),
8679
9231
  repo: dir,