SLOPSHOPPER

Repowise

Codebase intelligence for Claude Code. Indexes your codebase into five layers (Graph, Git, Docs, Decisions, Code Health) and exposes them through task-shaped…

newpanebandspinnerrowsguard
★ 7,373v0.55.0AGPL-3.0updated 2026-10-08repowise-dev/repowise/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repowise
│ ┃ Lens ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Flow 2: Map 3: Ask 4: Recap │ ┃ ▁▂▃▅▃▂▁▁▂▃▂▁▁▁▂▃▄▃▂▁▁▂▃▅▃▂▁▁▂▃▂▁▁▁▂▃▄▃▂▁▁▂▃▅ ● repowise: lens: change review failed: Error: repowise MCP server na │ ┃ ▃▂▁▁▂▃▂▁▁▁▂▃ ● repowise: lens: change review failed: Error: repowise MCP server na │ ┃ {^,^} Done · 42 s · edited 3 files ⚘ ⏺ Read(src/auth.ts) │ ┃ No Repowise calls · Claude opened 3 files ⎿ Read 6 lines │ ┃ WORKING SET · as of the last index ⏺ Update(src/auth.ts) │ ┃ src/auth.ts edited direct ⎿ Added 2 lines, removed 1 line │ ┃ src/audit.ts edited direct ⏺ Bash(bun test) │ ┃ src/cache.ts edited direct ⎿ 3 pass, 1 fail │ ┃ CONTEXT │ ┃ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░░░░·· ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▓ file reads 193 B ▒ search 30 B ░ shel… │ ┃ · other 152 B first edit after 1 ms ✻ Worked for 42s · done 4:20 PM │ ┃ ▔▔▀▔▔ ▔▔▔ ▔▔▀▔▔ ▔▔▀▔▔ ▔▔▔ ▔▔▀▔▔ · Change review could not run: repowise MCP server name not resolved │ ┃ ▔▔▀▔▔ ▔▔▔ ▔▔ │ › /lens │ ● repowise: lens: change review failed: Error: repowise MCP server na │ ● repowise: lens: change review failed: Error: repowise MCP server na │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lens
1: Flow 2: Map 3: Ask 4: Recap ▁▂▃▅▃▂▁▁▂▃▂▁▁▁▂▃▄▃▂▁▁▂▃▅▃▂▁▁▂▃▂▁▁▁▂▃▄▃▂▁▁▂▃▅▃▂▁▁▂▃▂▁▁▁▂▃ {^,^} Done · 42 s · edited 3 files ⚘ No Repowise calls · Claude opened 3 files WORKING SET · as of the last index src/auth.ts edited direct src/audit.ts edited direct src/cache.ts edited direct CONTEXT ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░░░░············ ▓ file reads 193 B ▒ search 30 B ░ shell 309 B · other 152 B first edit after 1 ms ▔▔▀▔▔ ▔▔▔ ▔▔▀▔▔ ▔▔▀▔▔ ▔▔▔ ▔▔▀▔▔ ▔▔▀▔▔ ▔▔▔ ▔▔
README

Repowise Plugin for Claude Code

Gives Claude Code deep understanding of your codebase — architecture, ownership, hotspots, dependencies, architectural decisions, and a defect-validated code-health score. Claude answers "why does auth work this way?" instead of "here's what auth.ts contains" — with fewer greps, fewer file reads, and lower cost per query.

Install

From the marketplace

/plugin marketplace add repowise-dev/repowise
/plugin install repowise@repowise

Local development

claude --plugin-dir ./plugins/claude-code

Quick start

After installing the plugin, run:

/repowise:init

Claude walks you through everything: installing repowise, choosing a mode, configuring your LLM provider, and indexing your codebase. Once indexed, the MCP tools and skills activate automatically.

What you get

Five intelligence layers

Graph (tree-sitter dependency graph, 16 languages) · Git (hotspots, ownership, co-change, bus factor) · Docs (LLM-generated wiki + semantic search) · Decisions (architectural rationale mined from five sources) · Code Health (1–10 defect-validated score from deterministic markers).

Slash commands

CommandWhat it does
/repowise:initInteractive setup — installs repowise, asks your preferences, indexes your codebase
/repowise:statusHealth check — sync state, page counts, provider info
/repowise:updateIncremental update — sync the index with recent code changes
/repowise:searchSearch the codebase wiki (fulltext, semantic, or symbol)
/repowise:askCited, synthesised answer to a codebase question (LLM call)
/repowise:contextTriage card for files/modules/symbols (layer, hotspot, freshness)
/repowise:symbolRead one symbol body with live-verified line bounds
/repowise:reindexRebuild the vector store (re-embed; no LLM calls)
/repowise:healthCode-health KPIs, lowest-scoring files, refactoring targets, trends
/repowise:coverageIngest or inspect coverage reports (lights up untested hotspots + per-test map)
/repowise:impacted-testsTests whose coverage intersects a change (commit / range / staged)
/repowise:riskRepo-relative review priority plus a supporting diff-shape score
/repowise:securityFull-history secret scan (repowise security scan --history)
/repowise:dead-codeUnreachable files, unused exports, zombie packages by confidence
/repowise:exportExport wiki pages or a Structurizr architecture model
/repowise:decisionList, inspect, add, or confirm architectural decisions
/repowise:whyWhy the code is shaped this way (decisions + archaeology)
/repowise:doctorDiagnose (and optionally repair) the setup, keys, and index drift

Automatic skills

Claude uses these when relevant — no slash command needed:

  • Codebase exploration — routes questions to get_overview / get_answer / search_codebase / get_context / get_symbol instead of raw file reads.
  • Pre-modification check — calls get_risk (and get_health for refactors) before editing to assess blast radius.
  • Change review — for a PR / branch / working-tree diff, combines get_change_risk (whole-change score) with get_risk's per-file directive block (will-break / missing co-changes / missing tests).
  • Code health — answers quality / complexity / "what to refactor" via get_health.
  • Architectural decisions — queries get_why for the why before architectural changes.
  • Dead-code cleanup — uses get_dead_code, conservatively, during cleanup.

MCP tools (10 flagship + list_repos)

Registered automatically when the plugin is enabled. The default single-repo surface is the ten flagship tools below plus list_repos (see MCP_TOOLS.md):

ToolWhat it answers
get_overviewArchitecture summary, module map, entry points, git health
get_answerCited, synthesised answer to a code question + a calibrated confidence
get_contextTriage card (docs, signatures, hotspot bit, callers, decisions) for files/modules/symbols
get_symbolRaw source of one symbol with exact line bounds
search_codebaseHybrid code search — mode="auto" routes identifiers to indexed symbols, paths to files, prose to semantic wiki search
get_riskPer-file hotspot, dependents, co-changes, owners; PR directive block with changed_files
get_change_riskBenchmarked live-change percentile/classification plus supporting diff-shape score
get_whyArchitectural decisions — search, path-anchored, or health dashboard
get_dead_codeUnused/unreachable findings tiered by confidence
get_health1–10 code-health score and marker findings per file

Setup modes

ModeWhat you getRequirements
Default (keyless)Graph + Git + Code Health + Dead Code + a full wiki rendered from structureNothing
Model-written wikiSame, with LLM-written pages and decision miningProvider key
Local (Ollama)Model-written wiki, fully offlineOllama running

The keyless default makes zero LLM calls and still produces a complete wiki; a provider key changes who writes the pages, not whether you get them, and that layer can continue in the background. Run /repowise:init and Claude helps you choose.

Proactive context (hooks)

The plugin registers two hooks that run repowise-augment:

  • SessionStart (startup|resume|clear) — emits live index-freshness / trust context at session start.
  • PostToolUse after Grep|Glob|Read|Edit|Write|mcp__.*[Rr]epowise.*__.* — stays silent unless it has something asymmetric to add (rescuing a zero-result grep with the closest indexed symbol, ranking a flood of matches by graph centrality, flagging a stale read, or recording read-after-served MCP traffic).

No LLM, no network. (repowise init installs the same hooks in ~/.claude/settings.json; running both is safe — duplicate enrichment is de-duplicated.)

Lens

Lens is the part of the plugin you see, a Claude Code mod that ships in hooks/lens/. It adds the file's reach to the spinner, margin notes under edits, a row under output repowise distill shortened, a change review under Claude's answer after a turn that edits files, and a /lens pane with Flow (a dashboard of each turn: what an edit reaches, where Claude's context came from, what Repowise answered), a map of the repo lit by Claude's turn with its story underneath, Ask, and a session recap. Lens never blocks or rewrites Claude's tool calls, makes no model calls of its own, and sends Claude nothing unless you press a button. Ask questions that do not start with "why" go to get_answer, which may use the model your repo configures. See the footprint for the full list.

It needs Claude Code 2.1.287 or later and an indexed repo. The map and the savings row also need repowise serve --no-ui. Six userConfig toggles control it: lens_margin, lens_squeeze, lens_review and lens_flow (on by default), and lens_pane_autoopen and lens_map_health (off). In the map, j and k walk the files Claude's turn lit, z and u zoom into and out of a folder, x clears the selection and h switches health colours. On an older Claude Code, or where mods are switched off, the rest of the plugin works as before.

Guide: docs/agent/LENS.md

Requirements

  • Python 3.11+
  • Git (for the git-intelligence layer)
  • The repowise CLI on PATH (/repowise:init installs it)

Troubleshooting

MCP tools not connecting: run /repowise:init — the plugin auto-registers the MCP server, but the repowise binary must be installed and on PATH.

pip install fails on Windows: try python -m pip install repowise.

Semantic search / get_answer returns nothing: the wiki may be template-rendered (no embeddings). Run /repowise:reindex, or repowise generate to upgrade pages with a model.

Stale results after code changes: run /repowise:update.

What this plugin runs and sends

The plugin itself is Markdown (commands and skills), a hooks.json, an .mcp.json and the Lens module. Everything it runs comes from the repowise CLI you install with pip:

  • MCP server: .mcp.json starts repowise mcp, which reads the index in your repo's .repowise/ folder.
  • Hooks: the three hooks run repowise-augment when it is on PATH and do nothing otherwise. They read the local index only.
  • Lens: reads the local index through the plugin's own MCP server and, for the map, through repowise serve on a loopback address. It contacts no other host. The Lens list below says exactly what it runs and reads.

Network traffic from the repowise CLI and MCP server:

  • Anonymous usage telemetry to https://api.repowise.dev/telemetry/events: command and tool names, coarse counts and timings, and an anonymous install id. Never source code, file paths, repo or symbol names, or query text. Turn it off with DO_NOT_TRACK=1 or REPOWISE_TELEMETRY_DISABLED=1; details in docs/reference/TELEMETRY.md.
  • Your model and embedding provider, only if you configure one: page generation in init and update, and get_answer, send code excerpts and your question to that provider. With no provider configured nothing is sent, and get_answer answers from local retrieval.
  • Version checks and downloads: the CLI reads the latest version from PyPI, and repowise serve downloads its web UI from the project's GitHub release.
  • repowise.dev, only when you run an account command yourself (repowise login, repowise publish, or sending feedback).

What Lens runs and reads

Lens is the mod in hooks/lens/lens.js, built from packages/claude-mod. Everything it does goes through Claude Code's mods API:

  • Programs it runs ($.process.run, read-only, 5 second timeout): git rev-parse --is-inside-work-tree to tell whether the session is in a git repo, and git rev-parse HEAD plus git diff --name-only <indexed> <HEAD> -- to count the files changed since the last index (<indexed> is the commit in .repowise/state.json, used only when it is a bare hex sha). To check that a repowise serve or repowise update named in a lock file is still running it runs tasklist /FI "PID eq <pid>" /NH /FO CSV on Windows and ps -p <pid> -o pid= elsewhere.
  • Files it reads ($.fs): .repowise/state.json, .repowise/serve.lock.json and .repowise/.update.lock in the repo and the folders above the session's directory. It writes no files.
  • MCP tools it calls itself ($.mcp.call, on this plugin's own repowise server only): get_context for a file Claude starts reading or editing (the caller count and owner in the spinner), get_change_risk after a turn that edits files (the review card), and get_why or get_answer when you ask a question in the Ask tab or with /lens ask. get_answer may use the model your repo configures; the others are local. These calls go through Claude Code's permission rules like Claude's own; see Permissions below.
  • The one host it fetches ($.http.fetch): the repowise serve address in .repowise/serve.lock.json, and only when that address is plain http on a loopback host (127.0.0.1, localhost or ::1). It asks that server for its health, its repo list, the health map, the blast radius of a file Claude edited (the file's path) and the savings ledger. Nothing it reads from the conversation goes anywhere else.
  • What it reads from the conversation: each tool call's name, a few of its arguments (file paths, the start of a Bash command) and the size of its result; the first 160 characters of your prompt at turn.start; and at turn.complete the answer's text (to place the review card under it), the turn's duration and how it ended. It keeps these in memory for the session's Flow tab and map, and writes none of it to disk.
  • Prompts it submits ($.prompt.submit), only when you press the button: Run tests sends Run the tests Repowise names for this change, ...: <test ids>. Brief Claude (offered after a compaction) sends The context was compacted. This brief is built from the Repowise index and this session's edits: followed by up to three lines, Files edited:, Decisions in play: and Open review items:, at most 1,200 characters. Why submits nothing: it fills the Ask field with why <decision title> and waits for Enter.
  • Hooks that decide or change something: Lens registers no tool.check hook, so it never approves or denies a tool call. Its tool.call hook passes every call and result on unchanged. turn.complete adds the review card as a line under Claude's answer, which Claude does not read. command.run answers only /lens (opening the pane on the tab you name, and asking with /lens ask). classic.PostCompact only offers the brief, and classic.PostToolUse reads the note the Repowise hook added for an edit to show it in the margin; both pass their event and result on unchanged.
  • No reflective tricks: the bundle is plain, unminified esbuild output with no Proxy, getters or then methods of its own.
Permissions

Lens's own lookups ask Claude Code's permission like any MCP tool call. To let them run without a prompt, allow the four read-only tools once, for example with /permissions or in .claude/settings.json:

{
  "permissions": {
    "allow": [
      "mcp__plugin_repowise_repowise__get_context",
      "mcp__plugin_repowise_repowise__get_change_risk",
      "mcp__plugin_repowise_repowise__get_why",
      "mcp__plugin_repowise_repowise__get_answer"
    ]
  }
}

Without the rules, a lookup that is refused (in auto mode, dontAsk, or claude -p) leaves that part of Lens empty: no file card in the spinner, no review card, or an error line in the Ask tab. The rest of Lens works.

License

AGPL-3.0, same as repowise. See the repository.

Source 1 files
hooks/lens/lens.js 4958 lines
1// Generated by packages/claude-mod (npm run build:mod). Do not edit.
2// ../api-client/src/client.ts
3var config = { baseUrl: "" };
4function configureApiClient(next) {
5  config = next;
6  BASE_URL = next.baseUrl;
7}
8var BASE_URL = "";
9function resolveToken() {
10  const { token } = config;
11  if (typeof token === "function") return token();
12  return token ?? null;
13}
14function requestHeaders(extra) {
15  const headers = {
16    "Content-Type": "application/json",
17    ...extra
18  };
19  const token = resolveToken();
20  if (token) {
21    headers["Authorization"] = `Bearer ${token}`;
22  }
23  return headers;
24}
25var ApiClientError = class extends Error {
26  constructor(status, detail) {
27    super(`API error ${status}: ${detail}`);
28    this.status = status;
29    this.detail = detail;
30    this.name = "ApiClientError";
31  }
32  status;
33  detail;
34};
35function stringifyDetail(detail) {
36  if (typeof detail === "string") return detail;
37  if (Array.isArray(detail)) {
38    const msgs = detail.map(
39      (d) => d && typeof d === "object" && "msg" in d ? String(d.msg) : null
40    ).filter((m) => !!m).map((m) => m.replace(/^Value error, /, ""));
41    if (msgs.length > 0) return msgs.join("; ");
42  }
43  return null;
44}
45async function handleResponse(res) {
46  if (!res.ok) {
47    let detail = res.statusText;
48    try {
49      const json = await res.json();
50      detail = stringifyDetail(json.detail) ?? detail;
51    } catch {
52    }
53    throw new ApiClientError(res.status, detail);
54  }
55  if (res.status === 204) return void 0;
56  return res.json();
57}
58function doFetch(url, init) {
59  return (config.fetch ?? fetch)(url, init);
60}
61function headersToRecord(headers) {
62  const out = {};
63  if (!headers) return out;
64  if (Array.isArray(headers)) {
65    for (const [name, value] of headers) out[name] = value;
66  } else if (typeof headers.forEach === "function") {
67    headers.forEach((value, name) => {
68      out[name] = value;
69    });
70  } else {
71    Object.assign(out, headers);
72  }
73  return out;
74}
75function createAdapterFetch(minimal) {
76  const adapted = async (input2, init) => {
77    const url = typeof input2 === "string" ? input2 : "href" in input2 ? input2.href : input2.url;
78    const body = init?.body;
79    if (body != null && typeof body !== "string") {
80      throw new TypeError("createAdapterFetch only sends string bodies");
81    }
82    const request = { headers: headersToRecord(init?.headers) };
83    if (init?.method) request.method = init.method;
84    if (body != null) request.body = body;
85    if (init?.signal) request.signal = init.signal;
86    const res = await minimal(url, request);
87    return {
88      ok: res.ok,
89      status: res.status,
90      statusText: `HTTP ${res.status}`,
91      json: async () => JSON.parse(res.text),
92      text: async () => res.text
93    };
94  };
95  return adapted;
96}
97async function apiGet(path, params, fetchOptions) {
98  const url = new URL(`${BASE_URL}${path}`, typeof window !== "undefined" ? window.location.href : "http://localhost");
99  if (params) {
100    for (const [k, v] of Object.entries(params)) {
101      if (v !== void 0 && v !== null) {
102        url.searchParams.set(k, String(v));
103      }
104    }
105  }
106  const res = await doFetch(url.toString(), {
107    method: "GET",
108    headers: requestHeaders(),
109    ...fetchOptions
110  });
111  return handleResponse(res);
112}
113async function apiPost(path, body, fetchOptions, params) {
114  const url = new URL(`${BASE_URL}${path}`, typeof window !== "undefined" ? window.location.href : "http://localhost");
115  if (params) {
116    for (const [k, v] of Object.entries(params)) {
117      if (v !== void 0 && v !== null) {
118        url.searchParams.set(k, String(v));
119      }
120    }
121  }
122  const res = await doFetch(url.toString(), {
123    method: "POST",
124    headers: requestHeaders(),
125    ...body !== void 0 ? { body: JSON.stringify(body) } : {},
126    ...fetchOptions
127  });
128  return handleResponse(res);
129}
130
131// ../api-client/src/costs.ts
132async function getSavings(repoId, opts = {}) {
133  return apiGet(`/api/repos/${repoId}/savings`, {
134    days: opts.days
135  });
136}
137
138// ../api-client/src/health.ts
139async function getHealth() {
140  return apiGet("/health");
141}
142
143// ../api-client/src/repos.ts
144async function listRepos() {
145  return apiGet("/api/repos");
146}
147
148// ../types/src/repos.ts
149function normalizeRepoPath(p, isWindows) {
150  const slashed = isWindows ? p.replace(/\\/g, "/") : p;
151  const trimmed = slashed.replace(/\/+$/, "");
152  return isWindows ? trimmed.toLowerCase() : trimmed;
153}
154
155// ../types/src/serve-lock.ts
156function isServeLock(value) {
157  if (typeof value !== "object" || value === null) return false;
158  const v = value;
159  return typeof v.pid === "number" && typeof v.host === "string" && typeof v.port === "number" && typeof v.url === "string" && (typeof v.ui_port === "number" || v.ui_port === null) && typeof v.server_version === "string" && typeof v.started_at === "string";
160}
161var LOOPBACK_V4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
162function isLoopbackUrl(url) {
163  try {
164    const u = new URL(url);
165    if (u.protocol !== "http:" && u.protocol !== "https:") return false;
166    return u.hostname === "localhost" || u.hostname === "[::1]" || LOOPBACK_V4.test(u.hostname);
167  } catch {
168    return false;
169  }
170}
171
172// src/data/transport.ts
173function connectApiClient(host, baseUrl) {
174  configureApiClient({ baseUrl, fetch: createAdapterFetch(host.http) });
175}
176var TimeoutError = class extends Error {
177  constructor(label, ms) {
178    super(`${label} timed out after ${ms} ms`);
179    this.name = "TimeoutError";
180  }
181};
182function withTimeout(work, ms, label) {
183  let timer;
184  const timeout = new Promise((_, reject) => {
185    timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms);
186  });
187  return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
188}
189
190// src/data/discovery.ts
191var HEALTH_TIMEOUT_MS = 800;
192var LIST_TIMEOUT_MS = 3e3;
193var CONNECT_TIMEOUT_MS = 35e3;
194var GIT_TIMEOUT_MS = 5e3;
195var MAX_WALK = 20;
196var SHA = /^[0-9a-f]{7,64}$/i;
197function isWindowsPath(p) {
198  return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
199}
200function join(dir, rel2) {
201  return `${dir.replace(/[\\/]+$/, "")}/${rel2}`;
202}
203function parentDir(p) {
204  const trimmed = p.replace(/[\\/]+$/, "");
205  const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
206  if (cut < 0) return null;
207  if (cut === 0) return trimmed === "" ? null : "/";
208  return trimmed.slice(0, cut);
209}
210async function findIndexedRoot(host, cwd2) {
211  let dir = cwd2;
212  for (let i = 0; dir !== null && i < MAX_WALK; i++) {
213    if (await host.fs.exists(join(dir, ".repowise/state.json"))) return dir;
214    dir = parentDir(dir);
215  }
216  return null;
217}
218async function readJson(host, path) {
219  try {
220    return JSON.parse(await host.fs.read(path));
221  } catch {
222    return null;
223  }
224}
225async function readServeLock(host, repoRoot) {
226  const parsed = await readJson(host, join(repoRoot, ".repowise/serve.lock.json"));
227  return isServeLock(parsed) ? parsed : null;
228}
229async function isPidAlive(host, pid, cwd2) {
230  try {
231    if (isWindowsPath(cwd2)) {
232      const out2 = await host.process.run(["tasklist", "/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"], cwd2);
233      return out2.exitCode === 0 && out2.stdout.includes(`"${pid}"`);
234    }
235    const out = await host.process.run(["ps", "-p", String(pid), "-o", "pid="], cwd2);
236    return out.exitCode === 0 && out.stdout.trim() === String(pid);
237  } catch {
238    return null;
239  }
240}
241async function probeServer(host, lock, repoRoot) {
242  connectApiClient(host, lock.url);
243  try {
244    await withTimeout(getHealth(), HEALTH_TIMEOUT_MS, "health");
245  } catch {
246    return { kind: "down" };
247  }
248  try {
249    const repos = await withTimeout(listRepos(), LIST_TIMEOUT_MS, "listRepos");
250    const win = isWindowsPath(repoRoot);
251    const target = normalizeRepoPath(repoRoot, win);
252    const match = repos.find((r) => r.local_path && normalizeRepoPath(r.local_path, win) === target);
253    return match ? { kind: "ok", repoId: match.id, updatedAt: match.updated_at ?? null } : { kind: "unlisted" };
254  } catch (err) {
255    if (err instanceof ApiClientError && (err.status === 401 || err.status === 403)) return { kind: "auth" };
256    return { kind: "down" };
257  }
258}
259async function mcpReachable(host) {
260  try {
261    return await withTimeout(host.mcp.connect(), CONNECT_TIMEOUT_MS, "mcp connect");
262  } catch {
263    return false;
264  }
265}
266async function unindexedMode(host, cwd2) {
267  const inWorkTree = (await git(host, cwd2, ["rev-parse", "--is-inside-work-tree"]))?.trim() === "true";
268  if (!inWorkTree) return "no-repo";
269  return await mcpReachable(host) ? "no-index" : "no-cli";
270}
271async function serverState(host, repoRoot) {
272  const lock = await readServeLock(host, repoRoot);
273  if (!lock || !isLoopbackUrl(lock.url)) return { kind: "down" };
274  if (await isPidAlive(host, lock.pid, repoRoot) === false) return { kind: "down" };
275  return probeServer(host, lock, repoRoot);
276}
277async function discover(host) {
278  const cwd2 = await host.session.cwd();
279  const repoRoot = await findIndexedRoot(host, cwd2);
280  if (repoRoot === null) return { mode: await unindexedMode(host, cwd2), repoRoot };
281  const server = await serverState(host, repoRoot);
282  if (server.kind === "ok") return { mode: "full", repoRoot, repo: { id: server.repoId, updatedAt: server.updatedAt } };
283  if (!await mcpReachable(host)) return { mode: "no-cli", repoRoot };
284  const liteReason = server.kind === "down" ? "no-server" : server.kind;
285  return { mode: "lite", liteReason, repoRoot };
286}
287async function git(host, cwd2, args) {
288  try {
289    const out = await withTimeout(host.process.run(["git", ...args], cwd2), GIT_TIMEOUT_MS, "git");
290    return out.exitCode === 0 ? out.stdout : null;
291  } catch {
292    return null;
293  }
294}
295async function updateRunning(host, repoRoot) {
296  const lock = await readJson(host, join(repoRoot, ".repowise/.update.lock"));
297  const pid = lock?.pid;
298  return typeof pid === "number" && await isPidAlive(host, pid, repoRoot) === true;
299}
300async function readFreshness(host, repoRoot) {
301  const state2 = await readJson(host, join(repoRoot, ".repowise/state.json"));
302  const indexed = state2?.last_sync_commit;
303  if (typeof indexed !== "string" || !SHA.test(indexed)) return null;
304  const head = (await git(host, repoRoot, ["rev-parse", "HEAD"]))?.trim();
305  if (!head || !SHA.test(head) || head === indexed) return null;
306  if (await updateRunning(host, repoRoot)) return null;
307  const diff = await git(host, repoRoot, ["diff", "--name-only", indexed, head, "--"]);
308  const changedFiles = diff === null ? null : diff.split("\n").filter((l) => l.trim() !== "").length;
309  return { changedFiles };
310}
311
312// src/data/mcp.ts
313var PLUGIN_SERVER_FORMS = ["plugin:repowise:repowise", "plugin_repowise_repowise"];
314var DEFAULT_TIMEOUT_MS = 1e4;
315var resolvedServer = null;
316var resolving = null;
317function resetMcp() {
318  resolvedServer = null;
319  resolving = null;
320}
321function warmMcp(host) {
322  if (resolvedServer !== null || resolving !== null) return;
323  const asked = host.mcp.server().then((name) => {
324    if (name !== null && PLUGIN_SERVER_FORMS.includes(name)) resolvedServer = name;
325  }).catch(() => void 0).finally(() => {
326    if (resolving === asked) resolving = null;
327  });
328  resolving = asked;
329}
330async function mcpReady(host, timeoutMs = DEFAULT_TIMEOUT_MS) {
331  warmMcp(host);
332  const pending = resolving;
333  if (resolvedServer === null && pending !== null) await withTimeout(pending, timeoutMs, "MCP connect").catch(() => void 0);
334  return resolvedServer !== null;
335}
336function parse(result, tool) {
337  const text2 = result.content[0]?.text;
338  if (result.isError) throw new Error(`${tool} failed: ${String(text2).slice(0, 200)}`);
339  if (typeof text2 !== "string") throw new Error(`${tool} returned no text`);
340  const parsed = JSON.parse(text2);
341  return parsed !== null && typeof parsed === "object" && "result" in parsed ? parsed.result : parsed;
342}
343function callTool(host, tool, args, opts = {}) {
344  if (resolvedServer === null) {
345    warmMcp(host);
346    return Promise.reject(new Error("repowise MCP server name not resolved yet"));
347  }
348  const call = host.mcp.call(resolvedServer, tool, args).then((r) => parse(r, tool));
349  return withTimeout(call, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, tool);
350}
351function recentOwner(o) {
352  const name = o?.recent_owner;
353  const share = o?.recent_owner_pct;
354  return typeof name === "string" && name !== "" && typeof share === "number" ? { name, share } : null;
355}
356async function fetchFileContext(host, path) {
357  const result = await callTool(host, "get_context", {
358    targets: [path],
359    include: ["callers", "ownership"]
360  });
361  const target = result.targets?.[path];
362  if (target === void 0 || target.error !== void 0) return null;
363  const callers = target.callers_total ?? target.callers?.length;
364  const contributors = target.ownership?.contributor_count;
365  return {
366    callerFiles: typeof callers === "number" ? callers : null,
367    contributors: typeof contributors === "number" ? contributors : null,
368    hotspot: typeof target.hotspot === "boolean" ? target.hotspot : null,
369    recentOwner: recentOwner(target.ownership)
370  };
371}
372
373// ../ui/src/lib/format.ts
374function parseDate(date) {
375  if (date instanceof Date) return date;
376  if (!/[zZ]|[+-]\d\d?:\d\d$/.test(date.trim())) {
377    return /* @__PURE__ */ new Date(`${date.trim()}Z`);
378  }
379  return new Date(date);
380}
381var NUMBER_FORMAT = new Intl.NumberFormat("en-US");
382function formatNumber(n) {
383  return NUMBER_FORMAT.format(n);
384}
385function formatRelativeTime(date) {
386  const d = parseDate(date);
387  const now = Date.now();
388  const diff = now - d.getTime();
389  const seconds = Math.floor(diff / 1e3);
390  const minutes = Math.floor(seconds / 60);
391  const hours = Math.floor(minutes / 60);
392  const days = Math.floor(hours / 24);
393  const weeks = Math.floor(days / 7);
394  const months = Math.floor(days / 30);
395  if (seconds < 10) return "just now";
396  if (seconds < 60) return `${seconds}s ago`;
397  if (minutes < 60) return `${minutes}m ago`;
398  if (hours < 24) return `${hours}h ago`;
399  if (days < 7) return `${days}d ago`;
400  if (weeks < 5) return `${weeks}w ago`;
401  if (months < 12) return `${months}mo ago`;
402  return `${Math.floor(months / 12)}y ago`;
403}
404function formatRelativeTimeOrNull(iso, fallback = "—") {
405  if (!iso) return fallback;
406  const d = parseDate(iso);
407  if (Number.isNaN(d.getTime()) || d.getTime() > Date.now()) return fallback;
408  return formatRelativeTime(d);
409}
410
411// src/format.ts
412function countOf(n, singular, plural) {
413  return `${formatNumber(n)} ${n === 1 ? singular : plural}`;
414}
415function fit(text2, columns) {
416  if (columns <= 0) return "";
417  if (text2.length <= columns) return text2;
418  return `${text2.slice(0, columns - 1).trimEnd()}…`;
419}
420var NOT_ONE_CELL = /[\u0000-\u001f\u007f-\u009f\ud800-\udfffᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/;
421function cellSafe(text2) {
422  return Array.from(text2, (ch) => ch.length === 1 && !NOT_ONE_CELL.test(ch) ? ch : "?").join("");
423}
424
425// ../../node_modules/d3-hierarchy/src/hierarchy/count.js
426function count(node) {
427  var sum = 0, children = node.children, i = children && children.length;
428  if (!i) sum = 1;
429  else while (--i >= 0) sum += children[i].value;
430  node.value = sum;
431}
432function count_default() {
433  return this.eachAfter(count);
434}
435
436// ../../node_modules/d3-hierarchy/src/hierarchy/each.js
437function each_default(callback, that) {
438  let index = -1;
439  for (const node of this) {
440    callback.call(that, node, ++index, this);
441  }
442  return this;
443}
444
445// ../../node_modules/d3-hierarchy/src/hierarchy/eachBefore.js
446function eachBefore_default(callback, that) {
447  var node = this, nodes = [node], children, i, index = -1;
448  while (node = nodes.pop()) {
449    callback.call(that, node, ++index, this);
450    if (children = node.children) {
451      for (i = children.length - 1; i >= 0; --i) {
452        nodes.push(children[i]);
453      }
454    }
455  }
456  return this;
457}
458
459// ../../node_modules/d3-hierarchy/src/hierarchy/eachAfter.js
460function eachAfter_default(callback, that) {
461  var node = this, nodes = [node], next = [], children, i, n, index = -1;
462  while (node = nodes.pop()) {
463    next.push(node);
464    if (children = node.children) {
465      for (i = 0, n = children.length; i < n; ++i) {
466        nodes.push(children[i]);
467      }
468    }
469  }
470  while (node = next.pop()) {
471    callback.call(that, node, ++index, this);
472  }
473  return this;
474}
475
476// ../../node_modules/d3-hierarchy/src/hierarchy/find.js
477function find_default(callback, that) {
478  let index = -1;
479  for (const node of this) {
480    if (callback.call(that, node, ++index, this)) {
481      return node;
482    }
483  }
484}
485
486// ../../node_modules/d3-hierarchy/src/hierarchy/sum.js
487function sum_default(value) {
488  return this.eachAfter(function(node) {
489    var sum = +value(node.data) || 0, children = node.children, i = children && children.length;
490    while (--i >= 0) sum += children[i].value;
491    node.value = sum;
492  });
493}
494
495// ../../node_modules/d3-hierarchy/src/hierarchy/sort.js
496function sort_default(compare) {
497  return this.eachBefore(function(node) {
498    if (node.children) {
499      node.children.sort(compare);
500    }
501  });
502}
503
504// ../../node_modules/d3-hierarchy/src/hierarchy/path.js
505function path_default(end) {
506  var start = this, ancestor = leastCommonAncestor(start, end), nodes = [start];
507  while (start !== ancestor) {
508    start = start.parent;
509    nodes.push(start);
510  }
511  var k = nodes.length;
512  while (end !== ancestor) {
513    nodes.splice(k, 0, end);
514    end = end.parent;
515  }
516  return nodes;
517}
518function leastCommonAncestor(a, b) {
519  if (a === b) return a;
520  var aNodes = a.ancestors(), bNodes = b.ancestors(), c = null;
521  a = aNodes.pop();
522  b = bNodes.pop();
523  while (a === b) {
524    c = a;
525    a = aNodes.pop();
526    b = bNodes.pop();
527  }
528  return c;
529}
530
531// ../../node_modules/d3-hierarchy/src/hierarchy/ancestors.js
532function ancestors_default() {
533  var node = this, nodes = [node];
534  while (node = node.parent) {
535    nodes.push(node);
536  }
537  return nodes;
538}
539
540// ../../node_modules/d3-hierarchy/src/hierarchy/descendants.js
541function descendants_default() {
542  return Array.from(this);
543}
544
545// ../../node_modules/d3-hierarchy/src/hierarchy/leaves.js
546function leaves_default() {
547  var leaves = [];
548  this.eachBefore(function(node) {
549    if (!node.children) {
550      leaves.push(node);
551    }
552  });
553  return leaves;
554}
555
556// ../../node_modules/d3-hierarchy/src/hierarchy/links.js
557function links_default() {
558  var root = this, links = [];
559  root.each(function(node) {
560    if (node !== root) {
561      links.push({ source: node.parent, target: node });
562    }
563  });
564  return links;
565}
566
567// ../../node_modules/d3-hierarchy/src/hierarchy/iterator.js
568function* iterator_default() {
569  var node = this, current, next = [node], children, i, n;
570  do {
571    current = next.reverse(), next = [];
572    while (node = current.pop()) {
573      yield node;
574      if (children = node.children) {
575        for (i = 0, n = children.length; i < n; ++i) {
576          next.push(children[i]);
577        }
578      }
579    }
580  } while (next.length);
581}
582
583// ../../node_modules/d3-hierarchy/src/hierarchy/index.js
584function hierarchy(data, children) {
585  if (data instanceof Map) {
586    data = [void 0, data];
587    if (children === void 0) children = mapChildren;
588  } else if (children === void 0) {
589    children = objectChildren;
590  }
591  var root = new Node(data), node, nodes = [root], child, childs, i, n;
592  while (node = nodes.pop()) {
593    if ((childs = children(node.data)) && (n = (childs = Array.from(childs)).length)) {
594      node.children = childs;
595      for (i = n - 1; i >= 0; --i) {
596        nodes.push(child = childs[i] = new Node(childs[i]));
597        child.parent = node;
598        child.depth = node.depth + 1;
599      }
600    }
601  }
602  return root.eachBefore(computeHeight);
603}
604function node_copy() {
605  return hierarchy(this).eachBefore(copyData);
606}
607function objectChildren(d) {
608  return d.children;
609}
610function mapChildren(d) {
611  return Array.isArray(d) ? d[1] : null;
612}
613function copyData(node) {
614  if (node.data.value !== void 0) node.value = node.data.value;
615  node.data = node.data.data;
616}
617function computeHeight(node) {
618  var height = 0;
619  do
620    node.height = height;
621  while ((node = node.parent) && node.height < ++height);
622}
623function Node(data) {
624  this.data = data;
625  this.depth = this.height = 0;
626  this.parent = null;
627}
628Node.prototype = hierarchy.prototype = {
629  constructor: Node,
630  count: count_default,
631  each: each_default,
632  eachAfter: eachAfter_default,
633  eachBefore: eachBefore_default,
634  find: find_default,
635  sum: sum_default,
636  sort: sort_default,
637  path: path_default,
638  ancestors: ancestors_default,
639  descendants: descendants_default,
640  leaves: leaves_default,
641  links: links_default,
642  copy: node_copy,
643  [Symbol.iterator]: iterator_default
644};
645
646// ../../node_modules/d3-hierarchy/src/accessors.js
647function required(f) {
648  if (typeof f !== "function") throw new Error();
649  return f;
650}
651
652// ../../node_modules/d3-hierarchy/src/constant.js
653function constantZero() {
654  return 0;
655}
656function constant_default(x) {
657  return function() {
658    return x;
659  };
660}
661
662// ../../node_modules/d3-hierarchy/src/treemap/round.js
663function round_default(node) {
664  node.x0 = Math.round(node.x0);
665  node.y0 = Math.round(node.y0);
666  node.x1 = Math.round(node.x1);
667  node.y1 = Math.round(node.y1);
668}
669
670// ../../node_modules/d3-hierarchy/src/treemap/dice.js
671function dice_default(parent, x0, y0, x1, y1) {
672  var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (x1 - x0) / parent.value;
673  while (++i < n) {
674    node = nodes[i], node.y0 = y0, node.y1 = y1;
675    node.x0 = x0, node.x1 = x0 += node.value * k;
676  }
677}
678
679// ../../node_modules/d3-hierarchy/src/treemap/slice.js
680function slice_default(parent, x0, y0, x1, y1) {
681  var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (y1 - y0) / parent.value;
682  while (++i < n) {
683    node = nodes[i], node.x0 = x0, node.x1 = x1;
684    node.y0 = y0, node.y1 = y0 += node.value * k;
685  }
686}
687
688// ../../node_modules/d3-hierarchy/src/treemap/squarify.js
689var phi = (1 + Math.sqrt(5)) / 2;
690function squarifyRatio(ratio, parent, x0, y0, x1, y1) {
691  var rows = [], nodes = parent.children, row2, nodeValue, i0 = 0, i1 = 0, n = nodes.length, dx, dy, value = parent.value, sumValue, minValue, maxValue, newRatio, minRatio, alpha, beta;
692  while (i0 < n) {
693    dx = x1 - x0, dy = y1 - y0;
694    do
695      sumValue = nodes[i1++].value;
696    while (!sumValue && i1 < n);
697    minValue = maxValue = sumValue;
698    alpha = Math.max(dy / dx, dx / dy) / (value * ratio);
699    beta = sumValue * sumValue * alpha;
700    minRatio = Math.max(maxValue / beta, beta / minValue);
701    for (; i1 < n; ++i1) {
702      sumValue += nodeValue = nodes[i1].value;
703      if (nodeValue < minValue) minValue = nodeValue;
704      if (nodeValue > maxValue) maxValue = nodeValue;
705      beta = sumValue * sumValue * alpha;
706      newRatio = Math.max(maxValue / beta, beta / minValue);
707      if (newRatio > minRatio) {
708        sumValue -= nodeValue;
709        break;
710      }
711      minRatio = newRatio;
712    }
713    rows.push(row2 = { value: sumValue, dice: dx < dy, children: nodes.slice(i0, i1) });
714    if (row2.dice) dice_default(row2, x0, y0, x1, value ? y0 += dy * sumValue / value : y1);
715    else slice_default(row2, x0, y0, value ? x0 += dx * sumValue / value : x1, y1);
716    value -= sumValue, i0 = i1;
717  }
718  return rows;
719}
720var squarify_default = (function custom(ratio) {
721  function squarify(parent, x0, y0, x1, y1) {
722    squarifyRatio(ratio, parent, x0, y0, x1, y1);
723  }
724  squarify.ratio = function(x) {
725    return custom((x = +x) > 1 ? x : 1);
726  };
727  return squarify;
728})(phi);
729
730// ../../node_modules/d3-hierarchy/src/treemap/index.js
731function treemap_default() {
732  var tile = squarify_default, round = false, dx = 1, dy = 1, paddingStack = [0], paddingInner = constantZero, paddingTop = constantZero, paddingRight = constantZero, paddingBottom = constantZero, paddingLeft = constantZero;
733  function treemap(root) {
734    root.x0 = root.y0 = 0;
735    root.x1 = dx;
736    root.y1 = dy;
737    root.eachBefore(positionNode);
738    paddingStack = [0];
739    if (round) root.eachBefore(round_default);
740    return root;
741  }
742  function positionNode(node) {
743    var p = paddingStack[node.depth], x0 = node.x0 + p, y0 = node.y0 + p, x1 = node.x1 - p, y1 = node.y1 - p;
744    if (x1 < x0) x0 = x1 = (x0 + x1) / 2;
745    if (y1 < y0) y0 = y1 = (y0 + y1) / 2;
746    node.x0 = x0;
747    node.y0 = y0;
748    node.x1 = x1;
749    node.y1 = y1;
750    if (node.children) {
751      p = paddingStack[node.depth + 1] = paddingInner(node) / 2;
752      x0 += paddingLeft(node) - p;
753      y0 += paddingTop(node) - p;
754      x1 -= paddingRight(node) - p;
755      y1 -= paddingBottom(node) - p;
756      if (x1 < x0) x0 = x1 = (x0 + x1) / 2;
757      if (y1 < y0) y0 = y1 = (y0 + y1) / 2;
758      tile(node, x0, y0, x1, y1);
759    }
760  }
761  treemap.round = function(x) {
762    return arguments.length ? (round = !!x, treemap) : round;
763  };
764  treemap.size = function(x) {
765    return arguments.length ? (dx = +x[0], dy = +x[1], treemap) : [dx, dy];
766  };
767  treemap.tile = function(x) {
768    return arguments.length ? (tile = required(x), treemap) : tile;
769  };
770  treemap.padding = function(x) {
771    return arguments.length ? treemap.paddingInner(x).paddingOuter(x) : treemap.paddingInner();
772  };
773  treemap.paddingInner = function(x) {
774    return arguments.length ? (paddingInner = typeof x === "function" ? x : constant_default(+x), treemap) : paddingInner;
775  };
776  treemap.paddingOuter = function(x) {
777    return arguments.length ? treemap.paddingTop(x).paddingRight(x).paddingBottom(x).paddingLeft(x) : treemap.paddingTop();
778  };
779  treemap.paddingTop = function(x) {
780    return arguments.length ? (paddingTop = typeof x === "function" ? x : constant_default(+x), treemap) : paddingTop;
781  };
782  treemap.paddingRight = function(x) {
783    return arguments.length ? (paddingRight = typeof x === "function" ? x : constant_default(+x), treemap) : paddingRight;
784  };
785  treemap.paddingBottom = function(x) {
786    return arguments.length ? (paddingBottom = typeof x === "function" ? x : constant_default(+x), treemap) : paddingBottom;
787  };
788  treemap.paddingLeft = function(x) {
789    return arguments.length ? (paddingLeft = typeof x === "function" ? x : constant_default(+x), treemap) : paddingLeft;
790  };
791  return treemap;
792}
793
794// ../types/src/health.ts
795var EXCELLENT_MIN = 8.5;
796var GOOD_MIN = 7;
797var FAIR_MIN = 5.5;
798var NEEDS_WORK_MIN = 4;
799var HEALTH_BAND_ORDER = [
800  "at_risk",
801  "needs_work",
802  "fair",
803  "good",
804  "excellent"
805];
806var HEALTH_BAND_LABEL = {
807  excellent: "Excellent",
808  good: "Good",
809  fair: "Fair",
810  needs_work: "Needs work",
811  at_risk: "At risk"
812};
813function bandForScore(score) {
814  if (score >= EXCELLENT_MIN) return "excellent";
815  if (score >= GOOD_MIN) return "good";
816  if (score >= FAIR_MIN) return "fair";
817  if (score >= NEEDS_WORK_MIN) return "needs_work";
818  return "at_risk";
819}
820
821// ../ui/src/brand.ts
822var BRAND = {
823  /** Accent orange — the brand fill (CTAs, owl eyes, highlights). */
824  accent: "#f59520",
825  /** Accent text on light surfaces (darkened for 4.5:1 contrast). */
826  accentTextLight: "#a16215",
827  /** Brand plum — logo strokes on light surfaces, secondary accent. */
828  plum: "#473659",
829  /** Plum near-black — primary ink on warm paper. */
830  ink: "#241b2c",
831  /** Warm paper — the light-mode page background. */
832  paper: "#fbf6f1",
833  /** Cream — the logo stroke color on dark surfaces. */
834  cream: "#fce9dd"
835};
836var LIGHT = {
837  bgRoot: "#fbf6f1",
838  bgSurface: "#ffffff",
839  bgElevated: "#fbf4ee",
840  bgInset: "#f4eae1",
841  textPrimary: "#241b2c",
842  textSecondary: "#5e5360",
843  textTertiary: "#8c7f88",
844  accentPrimary: "#a16215",
845  accentFill: "#f59520",
846  accentSecondary: "#58436c",
847  success: "#1d8155",
848  warning: "#9a6614",
849  error: "#b23a2e"
850};
851var DARK = {
852  bgRoot: "#0e0e0f",
853  bgSurface: "#141416",
854  bgElevated: "#191a1c",
855  bgInset: "#0a0a0b",
856  textPrimary: "#f2f2f3",
857  textSecondary: "#b4b4b9",
858  textTertiary: "#7c7c82",
859  accentPrimary: "#f59520",
860  accentFill: "#f59520",
861  accentSecondary: "#a98fc4",
862  success: "#34d399",
863  warning: "#f2a03d",
864  error: "#e06a5a"
865};
866var DARK_CANVAS = {
867  nodeAtRisk: "#b0544b",
868  nodeNeedsWork: "#bd7c42",
869  nodeFair: "#a89453",
870  nodeGood: "#42906f",
871  nodeExcellent: "#5cb389",
872  nodeNeutral: "#333336",
873  caution: "#d9b04a"
874};
875var DARK_CANVAS_BAND = {
876  excellent: DARK_CANVAS.nodeExcellent,
877  good: DARK_CANVAS.nodeGood,
878  fair: DARK_CANVAS.nodeFair,
879  needs_work: DARK_CANVAS.nodeNeedsWork,
880  at_risk: DARK_CANVAS.nodeAtRisk
881};
882
883// src/views/map.ts
884var UPPER_HALF = 9600;
885var LOWER_HALF = 9604;
886var SPACE = 32;
887var hex = (s) => parseInt(s.slice(1), 16);
888var GROUND = hex(DARK.bgRoot);
889var TERMINAL_DEFAULT = 16777216;
890var LABEL_FG = hex(DARK.textSecondary);
891var NEUTRAL = hex(DARK_CANVAS.nodeNeutral);
892var BLACK = 0;
893var MAP_PALETTES = {
894  dark: {
895    tileA: mix(hex(DARK.bgElevated), hex(DARK.textTertiary), 0.12),
896    tileB: mix(hex(DARK.bgElevated), hex(DARK.textTertiary), 0.22),
897    folder: hex(DARK.textTertiary),
898    hit: mix(hex(DARK.bgElevated), hex(DARK.textSecondary), 0.32),
899    read: hex(DARK.textSecondary),
900    named: mix(hex(DARK.accentSecondary), hex(DARK.bgRoot), 0.45),
901    importer: hex(DARK.accentSecondary),
902    edit: hex(BRAND.accent),
903    ring: hex(BRAND.cream)
904  },
905  light: {
906    tileA: mix(hex(LIGHT.bgInset), hex(LIGHT.textTertiary), 0.08),
907    tileB: mix(hex(LIGHT.bgInset), hex(LIGHT.textTertiary), 0.16),
908    folder: hex(LIGHT.textTertiary),
909    hit: mix(hex(LIGHT.bgInset), hex(LIGHT.textSecondary), 0.3),
910    read: hex(LIGHT.textSecondary),
911    named: mix(hex(LIGHT.accentSecondary), hex(LIGHT.bgRoot), 0.55),
912    importer: hex(LIGHT.accentSecondary),
913    edit: hex(BRAND.accentTextLight),
914    ring: hex(LIGHT.textPrimary)
915  }
916};
917var DEFAULT_STYLE = { theme: "dark", health: false };
918var DENSE_SHARE = 0.6;
919var SPLIT_SHARE = 0.25;
920var MAX_SPLITS = 8;
921var GUTTER_MIN = 6;
922var EDGE_MIN = 3;
923function colorForScore(score) {
924  return score === null ? NEUTRAL : hex(DARK_CANVAS_BAND[bandForScore(score)]);
925}
926function mix(a, b, amount) {
927  const ch = (shift) => {
928    const x = a >> shift & 255;
929    const y = b >> shift & 255;
930    return Math.round(x + (y - x) * amount) << shift;
931  };
932  return (ch(16) | ch(8) | ch(0)) >>> 0;
933}
934var byName = (a, b) => a < b ? -1 : a > b ? 1 : 0;
935function dirsOf(path) {
936  return path.split("/").slice(0, -1);
937}
938function keyOf(path, split) {
939  const dirs = dirsOf(path);
940  let depth = Math.min(1, dirs.length);
941  while (depth < dirs.length && split.has(dirs.slice(0, depth).join("/"))) depth++;
942  return dirs.slice(0, depth).join("/");
943}
944function nextSplit(files, split, limit) {
945  const weight = /* @__PURE__ */ new Map();
946  for (const f of files) {
947    const key2 = keyOf(f.file_path, split);
948    weight.set(key2, (weight.get(key2) ?? 0) + f.nloc);
949  }
950  const heavy = [...weight].filter(([key2, w]) => key2 !== "" && w > limit).sort((a, b) => b[1] - a[1] || byName(a[0], b[0]));
951  for (const [key2] of heavy) {
952    const deeper = /* @__PURE__ */ new Set([...split, key2]);
953    const parts = new Set(files.filter((f) => keyOf(f.file_path, split) === key2).map((f) => keyOf(f.file_path, deeper)));
954    if (parts.size > 1) return key2;
955  }
956  return null;
957}
958function groupKeys(files) {
959  const split = /* @__PURE__ */ new Set();
960  const limit = files.reduce((sum, f) => sum + f.nloc, 0) * SPLIT_SHARE;
961  for (let i = 0; i < MAX_SPLITS; i++) {
962    const key2 = nextSplit(files, split, limit);
963    if (key2 === null) break;
964    split.add(key2);
965  }
966  return files.map((f) => keyOf(f.file_path, split));
967}
968var folderDatum = (name) => ({ name, children: [], kids: /* @__PURE__ */ new Map() });
969function insertFile(group, key2, file) {
970  const rel2 = key2 === "" ? file.file_path : file.file_path.slice(key2.length + 1);
971  let node = group;
972  for (const dir of rel2.split("/").slice(0, -1)) {
973    let next = node.kids?.get(dir);
974    if (next === void 0) {
975      next = folderDatum(dir);
976      node.kids?.set(dir, next);
977      node.children?.push(next);
978    }
979    node = next;
980  }
981  node.children?.push({ name: file.file_path, file });
982}
983function treemapOf(files, width2, height) {
984  const keys = groupKeys(files);
985  const groups = /* @__PURE__ */ new Map();
986  files.forEach((file, i) => {
987    const key2 = keys[i] ?? "";
988    const group = groups.get(key2) ?? folderDatum(key2);
989    groups.set(key2, group);
990    insertFile(group, key2, file);
991  });
992  const data = { name: "", children: [...groups.values()] };
993  const root = hierarchy(data).sum((d) => d.file?.nloc ?? 0).sort((a, b) => (b.value ?? 0) - (a.value ?? 0) || byName(a.data.name, b.data.name));
994  return treemap_default().tile(squarify_default).size([width2, height])(root);
995}
996var rectOf = (n) => ({ x: n.x0, y: n.y0, w: n.x1 - n.x0, h: n.y1 - n.y0 });
997function pixelSpan(from, to) {
998  return [Math.ceil(from - 0.5), Math.ceil(to - 0.5)];
999}
1000function groupInterior(rect, width2, height) {
1001  const [x0, x1] = pixelSpan(rect.x, rect.x + rect.w);
1002  const [y0, y1] = pixelSpan(rect.y, rect.y + rect.h);
1003  const gutter = x1 - x0 >= GUTTER_MIN && y1 - y0 >= GUTTER_MIN;
1004  return { x0, y0, x1: gutter && x1 < width2 ? x1 - 1 : x1, y1: gutter && y1 < height ? y1 - 1 : y1 };
1005}
1006function placeFile(leaf, group, inner) {
1007  const file = leaf.data.file;
1008  const rect = rectOf(leaf);
1009  const [x0, x1] = pixelSpan(rect.x, rect.x + rect.w);
1010  const [y0, y1] = pixelSpan(rect.y, rect.y + rect.h);
1011  return {
1012    path: file.file_path,
1013    score: file.score,
1014    nloc: file.nloc,
1015    group,
1016    rect,
1017    px0: Math.max(x0, inner.x0),
1018    py0: Math.max(y0, inner.y0),
1019    px1: Math.min(x1, inner.x1),
1020    py1: Math.min(y1, inner.y1)
1021  };
1022}
1023function tileFor(f, i, style) {
1024  const pal = MAP_PALETTES[style.theme];
1025  if (style.health) return colorForScore(f.score);
1026  return i % 2 === 0 ? pal.tileA : pal.tileB;
1027}
1028function paintBox(base, width2, box2, fill) {
1029  for (let y = box2.y0; y < box2.y1; y++) base.fill(fill, y * width2 + box2.x0, y * width2 + box2.x1);
1030}
1031function folderPath(group, node) {
1032  const below2 = node.ancestors().reverse().slice(2).map((n) => n.data.name);
1033  return [group.data.name, ...below2].filter((part) => part !== "").join("/");
1034}
1035function folderTiles(group, inner) {
1036  const out = [];
1037  for (const node of group.descendants()) {
1038    if (node === group || node.children === void 0) continue;
1039    const tone = (node.parent?.children ?? []).indexOf(node) % 2;
1040    const [x0, x1] = pixelSpan(node.x0, node.x1);
1041    const [y0, y1] = pixelSpan(node.y0, node.y1);
1042    const box2 = { x0: Math.max(x0, inner.x0), y0: Math.max(y0, inner.y0), x1: Math.min(x1, inner.x1), y1: Math.min(y1, inner.y1) };
1043    out.push({ path: folderPath(group, node), ...box2, tone });
1044  }
1045  return out;
1046}
1047var within = (f, x, y) => x >= f.x0 && x < f.x1 && y >= f.y0 && y < f.y1;
1048function folderAt(layout, x, y) {
1049  const tile = layout.folders.filter((f) => within(f, x, y)).at(-1);
1050  if (tile !== void 0) return tile.path;
1051  const group = layout.groups.find((g) => within({ x0: g.rect.x, y0: g.rect.y, x1: g.rect.x + g.rect.w, y1: g.rect.y + g.rect.h }, x, y));
1052  return group === void 0 || group.key === "" ? null : group.key;
1053}
1054function paintFile(base, width2, f, fill) {
1055  const edge = mix(fill, BLACK, 0.3);
1056  const edgeX = f.px1 - f.px0 >= EDGE_MIN ? f.px1 - 1 : -1;
1057  const edgeY = f.py1 - f.py0 >= EDGE_MIN ? f.py1 - 1 : -1;
1058  for (let y = f.py0; y < f.py1; y++) {
1059    base.fill(y === edgeY ? edge : fill, y * width2 + f.px0, y * width2 + f.px1);
1060    if (edgeX >= 0) base[y * width2 + edgeX] = edge;
1061  }
1062}
1063function labelText(key2, width2) {
1064  const parts = key2.split("/");
1065  const rungs = [key2, parts.slice(-2).join("/"), parts[parts.length - 1] ?? ""];
1066  const text2 = rungs.find((r) => r.length > 0 && r.length <= width2);
1067  return text2 !== void 0 && /^[\x20-\x7e]+$/.test(text2) ? text2 : null;
1068}
1069var LABEL_MIN_COLS = 12;
1070var LABEL_MIN_ROWS = 3;
1071var MAX_LABELS = 8;
1072function labelFor(g, columns, rows, root) {
1073  const col = Math.ceil(g.rect.x);
1074  const row2 = Math.ceil(g.rect.y / 2);
1075  const width2 = Math.min(columns, Math.floor(g.rect.x + g.rect.w)) - col - 1;
1076  const height = Math.floor((g.rect.y + g.rect.h) / 2) - row2;
1077  const small = height < LABEL_MIN_ROWS || width2 < LABEL_MIN_COLS;
1078  const key2 = root === null ? g.key : g.key.slice(root.length + 1);
1079  if (key2 === "" || small || row2 >= rows) return null;
1080  const text2 = labelText(key2, width2);
1081  return text2 === null ? null : { row: row2, col, text: text2 };
1082}
1083function labelsFor(groups, columns, rows, root = null) {
1084  return [...groups].sort((a, b) => b.nloc - a.nloc).map((g) => labelFor(g, columns, rows, root)).filter((l) => l !== null).slice(0, MAX_LABELS);
1085}
1086function fileLabel(f, rows) {
1087  const text2 = cellSafe(f.path.slice(f.path.lastIndexOf("/") + 1));
1088  const row2 = Math.ceil(f.py0 / 2);
1089  const roomy = f.px1 - f.px0 >= text2.length + 2 && Math.floor(f.py1 / 2) > row2;
1090  return roomy && row2 < rows ? { row: row2, col: f.px0 + 1, text: text2 } : null;
1091}
1092function fileLabels(files, rows, taken) {
1093  const used = new Set(taken.flatMap((l) => [...l.text].map((_, k) => `${l.row}:${l.col + k}`)));
1094  const out = [];
1095  for (const label of files.map((f) => fileLabel(f, rows))) {
1096    if (label === null) continue;
1097    const cells = [...label.text].map((_, k) => `${label.row}:${label.col + k}`);
1098    if (cells.some((c) => used.has(c))) continue;
1099    cells.forEach((c) => used.add(c));
1100    out.push(label);
1101  }
1102  return out;
1103}
1104function paintBase(map2, style, dense) {
1105  const { width: width2, height } = map2;
1106  const base = new Uint32Array(width2 * height).fill(GROUND);
1107  const pal = MAP_PALETTES[style.theme];
1108  const tone = (t) => t === 0 ? pal.tileB : pal.tileA;
1109  if (!style.health) map2.groups.forEach((g, i) => paintBox(base, width2, groupInterior(g.rect, width2, height), tone(i % 2)));
1110  if (!style.health) for (const f of map2.folders) paintBox(base, width2, f, tone(1 - f.tone));
1111  if (dense && !style.health) return base;
1112  for (const i of map2.drawn) paintFile(base, width2, map2.files[i], tileFor(map2.files[i], i, style));
1113  return base;
1114}
1115function filesUnder(feed, root) {
1116  const lines = feed.filter((f) => f.nloc > 0);
1117  return root === null ? lines : lines.filter((f) => f.file_path.startsWith(`${root}/`));
1118}
1119function layoutMap(feed, canvas) {
1120  const { columns, rows, caseInsensitive } = canvas;
1121  const style = canvas.style ?? DEFAULT_STYLE;
1122  const width2 = columns;
1123  const height = rows * 2;
1124  const zoom = canvas.root ?? null;
1125  const root = treemapOf(filesUnder(feed, zoom), width2, height);
1126  const groups = [];
1127  const files = [];
1128  const folders = [];
1129  for (const node of root.children ?? []) {
1130    const group = groups.push({ key: node.data.name, rect: rectOf(node), nloc: node.value ?? 0 }) - 1;
1131    const inner = groupInterior(rectOf(node), width2, height);
1132    for (const leaf of node.leaves()) files.push(placeFile(leaf, group, inner));
1133    folders.push(...folderTiles(node, inner));
1134  }
1135  const drawn = [];
1136  const index = /* @__PURE__ */ new Map();
1137  const lower = /* @__PURE__ */ new Map();
1138  files.forEach((f, i) => {
1139    index.set(caseInsensitive ? f.path.toLowerCase() : f.path, i);
1140    lower.set(f.path.toLowerCase(), i);
1141    if (f.px1 > f.px0 && f.py1 > f.py0) drawn.push(i);
1142  });
1143  const dense = drawn.length < files.length * DENSE_SHARE;
1144  const base = paintBase({ width: width2, height, groups, folders, files, drawn }, style, dense);
1145  const folderNames = labelsFor(groups, columns, rows, zoom);
1146  const labels = zoom === null || dense ? folderNames : [...folderNames, ...fileLabels(files, rows, folderNames)];
1147  return { columns, rows, width: width2, height, groups, files, drawn, index, caseInsensitive, labels, base, style, dense, lower, folders, root: zoom };
1148}
1149function halfCell(top, bottom) {
1150  if (top === GROUND && bottom === GROUND) return [SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT];
1151  if (top === GROUND) return [LOWER_HALF, bottom, TERMINAL_DEFAULT];
1152  return [UPPER_HALF, top, bottom === GROUND ? TERMINAL_DEFAULT : bottom];
1153}
1154function labelCovered(label, marked, width2) {
1155  if (marked === void 0) return false;
1156  for (let c = label.col; c < label.col + label.text.length; c++) {
1157    if (marked[2 * label.row * width2 + c] || marked[(2 * label.row + 1) * width2 + c]) return true;
1158  }
1159  return false;
1160}
1161var cellGround = (pixel) => pixel === void 0 || pixel === GROUND ? TERMINAL_DEFAULT : pixel;
1162function writeText(words, layout, label, colors) {
1163  if (label.row < 0 || label.row >= layout.rows) return;
1164  for (let k = Math.max(0, -label.col); k < label.text.length && label.col + k < layout.columns; k++) {
1165    const col = label.col + k;
1166    words.set([label.text.charCodeAt(k), ...colors(col)], (label.row * layout.columns + col) * 3);
1167  }
1168}
1169function writeFolderLabels(words, layout, pixels, marked) {
1170  const fg = layout.style.health ? LABEL_FG : MAP_PALETTES[layout.style.theme].folder;
1171  for (const label of layout.labels) {
1172    if (labelCovered(label, marked, layout.width)) continue;
1173    writeText(words, layout, label, (col) => [fg, cellGround(pixels[2 * label.row * layout.width + col])]);
1174  }
1175}
1176function encodeCells(layout, pixels, marked, names2 = []) {
1177  const { columns, rows, width: width2 } = layout;
1178  const words = new Uint32Array(columns * rows * 3);
1179  for (let i = 0; i < columns * rows; i++) {
1180    const r = Math.floor(i / columns);
1181    const c = i % columns;
1182    words.set(halfCell(pixels[2 * r * width2 + c] ?? GROUND, pixels[(2 * r + 1) * width2 + c] ?? GROUND), i * 3);
1183  }
1184  writeFolderLabels(words, layout, pixels, marked);
1185  for (const name of names2) writeText(words, layout, name, () => [name.color, TERMINAL_DEFAULT]);
1186  return new Uint8Array(words.buffer).toBase64();
1187}
1188
1189// src/views/overlay.ts
1190var NO_LIT = { hits: [], reads: [], named: [], importers: [], edits: [], edit: null, current: null };
1191var FRAME_MS = 40;
1192var FLASH_MS = 600;
1193var RIPPLE_MS = 1200;
1194var NAME_MAX = 18;
1195var FLASH_LIFT = 0.6;
1196var keyOf2 = (layout, path) => layout.caseInsensitive ? path.toLowerCase() : path;
1197function indices(layout, paths, lowered = false) {
1198  const out = [];
1199  for (const p of paths) {
1200    const i = lowered ? layout.lower.get(p) : layout.index.get(keyOf2(layout, p));